"use client"; /** * The CRM's AIProvider for the Smart Gallery. * * PROVENANCE: a port of the SDK demo's `createDemoAIProvider()` plus its * clip/face/ocr/tensorflow/runpod-yolo providers and `imageEncode.ts` helpers * (advance-photo-gallery-web-sdk/apps/web/src/lib/ai/*), collapsed into one * module and retargeted from `/api/ai/*` to `/api/gallery/ai/*`. * * Capability split: * - object detection: TensorFlow.js COCO-SSD, fully in-browser (no key), or the * RunPod YOLO classifier via /api/gallery/ai/classify when * NEXT_PUBLIC_APG_RUNPOD_DETECT=true (COCO-SSD is the automatic fallback) * - face detection + recognition: face-api.js in-browser → clustered into People * - OCR: tesseract.js in-browser → searchable text + the Documents album * - semantic search: CLIP via transformers.js in-browser * - background removal: @imgly in-browser WASM, or the RunPod U²-Net endpoint * when NEXT_PUBLIC_APG_RUNPOD_BG=true (in-browser is the fallback) * - other generative edits / transcription / denoise / tilt: proxied through the * server routes so the RunPod key never reaches the browser * * EVERY heavy model is behind `await import(...)` so none of it lands in the * initial bundle, and every capability degrades to []/''/null with a * console.warn rather than throwing — a failed model must never break the * gallery UI. * * The in-browser models fetch weights from public CDNs (jsdelivr, huggingface, * storage.googleapis.com, staticimgly.com). See docs/SMART_GALLERY.md for the * list that would need CSP allow-listing. */ import type { AIProvider, GenerativeEditOp, MediaItem } from "@photo-gallery/sdk"; // Derived from the provider interface so we import only the three public types. type DetectedObject = Awaited>>[number]; type DetectedFace = Awaited>>[number]; type ImageSource = ImageBitmap | HTMLImageElement; const API = "/api/gallery/ai"; // --------------------------------------------------------------------------- // imageEncode helpers (ported from apps/web/src/lib/ai/imageEncode.ts) // --------------------------------------------------------------------------- export interface EncodedImage { /** base64 JPEG (no data: prefix). */ data: string; mimeType: string; /** Actual pixel dims of the encoded image (after downscale). */ width: number; height: number; } /** Draw an image to a downscaled canvas and return base64 JPEG + its dims. */ export function imageToBase64(image: ImageSource, maxDim: number): EncodedImage { const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width; const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height; const scale = Math.min(1, maxDim / Math.max(w, h)); const cw = Math.max(1, Math.round(w * scale)); const ch = Math.max(1, Math.round(h * scale)); const canvas = document.createElement("canvas"); canvas.width = cw; canvas.height = ch; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas not supported."); ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch); const dataUrl = canvas.toDataURL("image/jpeg", 0.9); return { data: dataUrl.split(",")[1] ?? "", mimeType: "image/jpeg", width: cw, height: ch }; } /** * Rasterize a mask (ImageData, white = region to regenerate) to a PNG base64 * scaled to targetW×targetH so it matches the encoded image exactly — SD 3.5 * requires image and mask to be identical pixel sizes. */ export function maskToBase64(mask: ImageData, targetW: number, targetH: number): string { const tmp = document.createElement("canvas"); tmp.width = mask.width; tmp.height = mask.height; const tctx = tmp.getContext("2d"); if (!tctx) throw new Error("Canvas not supported."); tctx.putImageData(mask, 0, 0); const out = document.createElement("canvas"); out.width = targetW; out.height = targetH; const octx = out.getContext("2d"); if (!octx) throw new Error("Canvas not supported."); // Nearest-neighbour, not bilinear — keep the mask strictly binary so SD gets // crisp white(regenerate)/black(keep) edges instead of an anti-aliased grey halo. octx.imageSmoothingEnabled = false; octx.drawImage(tmp, 0, 0, targetW, targetH); return out.toDataURL("image/png").split(",")[1] ?? ""; } export function base64ToBlob(base64: string, mime: string): Blob { const bin = atob(base64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return new Blob([bytes], { type: mime }); } /** * Pad an image with a neutral border for outpaint and return {imageBase64, maskBase64} * as base64 PNG — the border is WHITE in the mask (regenerate), the original image * area BLACK (keep). Capped at 1280px on the long side. */ export function padForOutpaint( image: ImageSource, factor: number, ): { imageBase64: string; maskBase64: string } { const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width; const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height; const f = Math.max(1.1, Math.min(2, factor)); const maxDim = 1280; let pw = Math.round(w * f); let ph = Math.round(h * f); const scale = Math.min(1, maxDim / Math.max(pw, ph)); pw = Math.max(16, Math.round(pw * scale)); ph = Math.max(16, Math.round(ph * scale)); const iw = Math.max(1, Math.round(w * scale)); const ih = Math.max(1, Math.round(h * scale)); const ox = Math.floor((pw - iw) / 2); const oy = Math.floor((ph - ih) / 2); const imgCanvas = document.createElement("canvas"); imgCanvas.width = pw; imgCanvas.height = ph; const ictx = imgCanvas.getContext("2d"); if (!ictx) throw new Error("Canvas not supported."); // Fill the new border with a blurred, stretched copy of the photo so the model // has real color/context to continue from — flat gray gives it nothing. ictx.filter = "blur(28px)"; ictx.drawImage(image as CanvasImageSource, 0, 0, pw, ph); ictx.filter = "none"; ictx.drawImage(image as CanvasImageSource, ox, oy, iw, ih); const maskCanvas = document.createElement("canvas"); maskCanvas.width = pw; maskCanvas.height = ph; const mctx = maskCanvas.getContext("2d"); if (!mctx) throw new Error("Canvas not supported."); mctx.fillStyle = "#ffffff"; mctx.fillRect(0, 0, pw, ph); mctx.fillStyle = "#000000"; mctx.fillRect(ox, oy, iw, ih); return { imageBase64: imgCanvas.toDataURL("image/png").split(",")[1] ?? "", maskBase64: maskCanvas.toDataURL("image/png").split(",")[1] ?? "", }; } /** Draw an image to a canvas (downscaled) and return a JPEG Blob. */ function canvasBlob(image: ImageSource, maxDim: number): Promise { const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width; const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height; const scale = Math.min(1, maxDim / Math.max(w, h)); const cw = Math.max(1, Math.round(w * scale)); const ch = Math.max(1, Math.round(h * scale)); const canvas = document.createElement("canvas"); canvas.width = cw; canvas.height = ch; const ctx = canvas.getContext("2d"); if (!ctx) return Promise.reject(new Error("Canvas not supported.")); ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch); return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92), ); } function clamp01(n: number): number { return n < 0 ? 0 : n > 1 ? 1 : n; } // --------------------------------------------------------------------------- // Object detection — TensorFlow.js COCO-SSD, in-browser // --------------------------------------------------------------------------- interface CocoPrediction { bbox: [number, number, number, number]; class: string; score: number; } interface CocoModel { detect( img: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement, maxNumBoxes?: number, minScore?: number, ): Promise; } let cocoPromise: Promise | null = null; /** Load tfjs + COCO-SSD exactly once; resolves to null if anything fails. */ function ensureCoco(): Promise { cocoPromise ??= (async () => { try { const tf = await import("@tensorflow/tfjs"); try { await tf.setBackend("webgl"); } catch { // Fall back to the default backend if WebGL is unavailable. } await tf.ready(); const cocoSsd = await import("@tensorflow-models/coco-ssd"); return (await cocoSsd.load({ base: "lite_mobilenet_v2" })) as unknown as CocoModel; } catch (err) { console.warn("[gallery-ai] COCO-SSD load failed; object detection disabled.", err); return null; } })(); return cocoPromise; } async function detectObjectsInBrowser( item: MediaItem, image: ImageSource, ): Promise { const model = await ensureCoco(); if (!model) return []; try { const el = image as HTMLImageElement; const w = el.naturalWidth || el.width || item.width || 1; const h = el.naturalHeight || el.height || item.height || 1; const predictions = await model.detect(el, 20, 0.4); return predictions.map((p) => ({ label: p.class, confidence: p.score, box: { x: p.bbox[0] / w, y: p.bbox[1] / h, width: p.bbox[2] / w, height: p.bbox[3] / h }, })) as DetectedObject[]; } catch (err) { console.warn("[gallery-ai] object detection failed.", err); return []; } } /** * Server-side YOLO detection via /api/gallery/ai/classify, with the in-browser * COCO-SSD as the automatic fallback so detection never hard-fails. */ async function detectObjectsViaRunpod( item: MediaItem, image: ImageSource, ): Promise { try { const { data, mimeType, width, height } = imageToBase64(image, 1280); const res = await fetch(`${API}/classify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ imageBase64: data, mimeType, width, height }), }); if (!res.ok) throw new Error(`classify failed (${res.status})`); const { objects } = (await res.json()) as { objects?: DetectedObject[] }; if (Array.isArray(objects)) return objects; throw new Error("classify returned no objects"); } catch (err) { console.warn("[gallery-ai] RunPod detection failed; falling back to COCO-SSD.", err); return detectObjectsInBrowser(item, image); } } // --------------------------------------------------------------------------- // Faces — @vladmandic/face-api, in-browser (128-D descriptors → People) // --------------------------------------------------------------------------- const FACE_MODEL_URL = "https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.15/model"; type FaceApi = typeof import("@vladmandic/face-api"); let facePromise: Promise | null = null; function ensureFaceModels(): Promise { facePromise ??= (async () => { try { const faceapi = await import("@vladmandic/face-api"); // The bundled tf re-export is typed narrowly; backend control lives on the // runtime object. Prefer WebGL (no eval; CSP-friendly), fall back gracefully. const tf = faceapi.tf as unknown as { setBackend: (b: string) => Promise; ready: () => Promise; }; try { await tf.setBackend("webgl"); } catch { /* keep default backend */ } await tf.ready(); await Promise.all([ faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_URL), faceapi.nets.faceLandmark68Net.loadFromUri(FACE_MODEL_URL), faceapi.nets.faceRecognitionNet.loadFromUri(FACE_MODEL_URL), ]); return faceapi; } catch (err) { // Degrade gracefully — People simply stays empty if models can't load. console.warn("[gallery-ai] face model load failed; face clustering disabled.", err); return null; } })(); return facePromise; } let faceWarned = false; async function detectFaces(item: MediaItem, image: ImageSource): Promise { const faceapi = await ensureFaceModels(); if (!faceapi) return []; const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width || 1; const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height || 1; type TNetInput = Parameters[0]; let results; try { results = await faceapi .detectAllFaces( image as unknown as TNetInput, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.5 }), ) .withFaceLandmarks() .withFaceDescriptors(); } catch (err) { if (!faceWarned) { faceWarned = true; console.warn("[gallery-ai] face detection failed on", item.name, err); } return []; } return results.map((r) => { const b = r.detection.box; return { confidence: r.detection.score, box: { x: clamp01(b.x / w), y: clamp01(b.y / h), width: clamp01(b.width / w), height: clamp01(b.height / h), }, embedding: Array.from(r.descriptor), }; }) as DetectedFace[]; } // --------------------------------------------------------------------------- // OCR — tesseract.js, in-browser // --------------------------------------------------------------------------- // Must equal the EXACT tesseract.js version in package.json (pinned, no caret) // so the worker CDN URL can never drift from the installed main-thread code. const TESSERACT_VERSION = "5.1.1"; const WORKER_PATH = `https://cdn.jsdelivr.net/npm/tesseract.js@${TESSERACT_VERSION}/dist/worker.min.js`; const CORE_PATH = "https://cdn.jsdelivr.net/npm/tesseract.js-core@5"; // jsDelivr's GitHub mirror of naptha/tessdata (same files as projectnaptha.com), // so every asset comes from ONE host that a CSP can allow-list. const LANG_PATH = "https://cdn.jsdelivr.net/gh/naptha/tessdata@gh-pages/4.0.0"; interface OcrWord { text?: string; confidence?: number; } interface OcrData { text?: string; confidence?: number; words?: OcrWord[]; blocks?: Array<{ paragraphs?: Array<{ lines?: Array<{ words?: OcrWord[] }> }> }> | null; } type TesseractWorker = import("tesseract.js").Worker; let ocrWorkerPromise: Promise | null = null; function ensureOcrWorker(): Promise { ocrWorkerPromise ??= (async () => { try { const { createWorker } = await import("tesseract.js"); // v5: createWorker(langs, oem, options) already loads + initializes the // language internally — do NOT call the removed v4 worker.load(). return await createWorker("eng", 1, { workerPath: WORKER_PATH, corePath: CORE_PATH, langPath: LANG_PATH, }); } catch (err) { console.warn("[gallery-ai] tesseract worker init failed; OCR disabled.", err); return null; } })(); return ocrWorkerPromise; } const WORD_CONFIDENCE = 70; // a word tesseract is actually sure about const MIN_WORDS = 4; // need several confident words to call it a document const MIN_CHARS = 10; function collectWords(data: OcrData): OcrWord[] { if (Array.isArray(data.words) && data.words.length) return data.words; const out: OcrWord[] = []; for (const b of data.blocks ?? []) for (const p of b.paragraphs ?? []) for (const l of p.lines ?? []) for (const w of l.words ?? []) out.push(w); return out; } /** * Return real text or '' (not a document). tesseract hallucinates low-confidence * gibberish for photos with no text, so we keep only high-confidence, word-shaped * tokens and require several of them. */ function meaningfulText(data: OcrData): string { const words = collectWords(data); if (words.length > 0) { const good = words.filter( (w) => (w.confidence ?? 0) >= WORD_CONFIDENCE && /[A-Za-z0-9]{2,}/.test(w.text ?? ""), ); const text = good .map((w) => (w.text ?? "").trim()) .filter(Boolean) .join(" ") .trim(); return good.length >= MIN_WORDS && text.length >= MIN_CHARS ? text : ""; } // Fallback: overall confidence + count of word-shaped tokens. const raw = (data.text ?? "").trim(); const conf = typeof data.confidence === "number" ? data.confidence : 0; const realWords = raw.match(/[A-Za-z]{3,}/g) ?? []; return conf >= 72 && realWords.length >= 6 ? raw : ""; } async function ocr(_item: MediaItem, image: ImageSource): Promise { const worker = await ensureOcrWorker(); if (!worker) return ""; try { // Request the block hierarchy so per-word confidence is available. const { data } = (await worker.recognize( image as unknown as HTMLImageElement, {}, { text: true, blocks: true }, )) as { data: OcrData }; return meaningfulText(data); } catch (err) { console.warn("[gallery-ai] OCR failed.", err); return ""; } } // --------------------------------------------------------------------------- // Semantic search — CLIP via transformers.js (ONNX-WASM), in-browser // --------------------------------------------------------------------------- const CLIP_MODEL_ID = "Xenova/clip-vit-base-patch16"; type Transformers = typeof import("@huggingface/transformers"); let transformersMod: Transformers | null = null; /* eslint-disable @typescript-eslint/no-explicit-any -- transformers.js pipelines are untyped */ let clipVisionPromise: Promise<{ processor: any; model: any } | null> | null = null; let clipTextPromise: Promise<{ tokenizer: any; model: any } | null> | null = null; async function loadTransformers(): Promise { if (!transformersMod) { transformersMod = await import("@huggingface/transformers"); // Remote-only (models from the HF CDN); rely on browser cache between sessions. transformersMod.env.allowLocalModels = false; } return transformersMod; } function ensureClipVision() { clipVisionPromise ??= (async () => { try { const tf = await loadTransformers(); const [processor, model] = await Promise.all([ tf.AutoProcessor.from_pretrained(CLIP_MODEL_ID), tf.CLIPVisionModelWithProjection.from_pretrained(CLIP_MODEL_ID), ]); return { processor, model }; } catch (err) { console.warn("[gallery-ai] CLIP vision load failed; semantic search disabled.", err); return null; } })(); return clipVisionPromise; } function ensureClipText() { clipTextPromise ??= (async () => { try { const tf = await loadTransformers(); const [tokenizer, model] = await Promise.all([ tf.AutoTokenizer.from_pretrained(CLIP_MODEL_ID), tf.CLIPTextModelWithProjection.from_pretrained(CLIP_MODEL_ID), ]); return { tokenizer, model }; } catch (err) { console.warn("[gallery-ai] CLIP text load failed; semantic search disabled.", err); return null; } })(); return clipTextPromise; } /** Draw an image onto a canvas (downscaled) for the CLIP image processor. */ function toCanvas(image: ImageSource, maxDim = 384): HTMLCanvasElement { const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width; const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height; const scale = Math.min(1, maxDim / Math.max(w, h)); const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(w * scale)); canvas.height = Math.max(1, Math.round(h * scale)); const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas not supported."); ctx.drawImage(image as CanvasImageSource, 0, 0, canvas.width, canvas.height); return canvas; } function tensorToArray(t: any): number[] { const data: Float32Array = t?.data ?? t; return Array.from(data as ArrayLike); } async function embedImage(_item: MediaItem, image: ImageSource): Promise { const v = await ensureClipVision(); if (!v) return []; try { const tf = await loadTransformers(); const canvas = toCanvas(image); const ctx = canvas.getContext("2d"); if (!ctx) return []; const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const raw = new tf.RawImage(imageData.data, canvas.width, canvas.height, 4).rgb(); const inputs = await v.processor(raw); const out = await v.model(inputs); return tensorToArray(out.image_embeds); } catch (err) { console.warn("[gallery-ai] embedImage failed.", err); return []; } } async function embedText(query: string): Promise { const t = await ensureClipText(); if (!t) return []; try { const inputs = t.tokenizer([query], { padding: true, truncation: true }); const out = await t.model(inputs); return tensorToArray(out.text_embeds); } catch (err) { console.warn("[gallery-ai] embedText failed.", err); return []; } } /* eslint-enable @typescript-eslint/no-explicit-any */ // --------------------------------------------------------------------------- // The provider // --------------------------------------------------------------------------- /** `true` only for the literal string "true", matching the SDK demo's semantics. */ function flag(v: string | undefined): boolean { return v === "true"; } export function createCrmAIProvider(): AIProvider { // NEXT_PUBLIC_* are inlined at build time, so these must be read as full // static member expressions — do NOT refactor to dynamic indexing. const useRunpodDetect = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_DETECT); const useRunpodBg = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_BG); const useRunpodTilt = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_TILT); return { name: "crm-ai (coco-ssd/yolo + face-api + tesseract + clip + runpod-edit)", detectObjects: useRunpodDetect ? detectObjectsViaRunpod : detectObjectsInBrowser, detectFaces, ocr, embedImage, embedText, async generativeEdit(item: MediaItem, image: ImageSource, op: GenerativeEditOp) { // Remove Background runs fully in-browser (no key) via @imgly — works even // with no backend. Other ops go through the /api/gallery/ai/edit route. if (op.type === "remove-background") { // Prefer the RunPod U²-Net endpoint when enabled; fall back to in-browser // @imgly if it's off or the request fails, so this always produces a result. if (useRunpodBg) { try { const { data, mimeType } = imageToBase64(image, 1600); const res = await fetch(`${API}/edit`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ imageBase64: data, mimeType, op: { type: "remove-background" }, params: {}, }), }); if (res.ok) { const { imageBase64: out, mimeType: outMime } = (await res.json()) as { imageBase64: string; mimeType?: string; }; return base64ToBlob(out, outMime || "image/png"); } } catch { /* fall through to the in-browser remover */ } } const inputBlob = await canvasBlob(image, 1600); const { removeBackground } = await import("@imgly/background-removal"); return removeBackground(inputBlob, { output: { format: "image/png" } }); } // Outpaint / expand-canvas: pad the image with a neutral border, mark that // border WHITE in the mask, and run it through the same inpaint path as // generative-fill — no extra backend route needed. if (op.type === "outpaint") { const { imageBase64: padded, maskBase64: border } = padForOutpaint( image, typeof op.factor === "number" ? op.factor : 1.5, ); const outParams: Record = { strength: typeof op.strength === "number" ? op.strength : 0.85, }; const outRes = await fetch(`${API}/edit`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ imageBase64: padded, mimeType: "image/png", op: { type: "generative-fill", prompt: op.prompt || "Extend and continue the scene naturally, matching lighting, colors and perspective.", }, maskBase64: border, params: outParams, }), }); if (!outRes.ok) { const err = (await outRes.json().catch(() => ({}))) as { error?: string }; throw new Error(err.error || `AI request failed (${outRes.status}).`); } const outJson = (await outRes.json()) as { imageBase64: string; mimeType?: string }; return base64ToBlob(outJson.imageBase64, outJson.mimeType || "image/png"); } const { data, mimeType, width, height } = imageToBase64(image, 1280); // Masked ops carry an ImageData mask — rasterize it to a PNG matched to the // (downscaled) image dims, and strip it from the op since ImageData is not // JSON-serializable. const maskBase64 = "mask" in op ? maskToBase64(op.mask, width, height) : undefined; const wireOp: Record = { type: op.type }; if ("prompt" in op && typeof op.prompt === "string") wireOp.prompt = op.prompt; if ("factor" in op && typeof op.factor === "number") wireOp.factor = op.factor; // Forward the "edit strength" slider (0..1) so the backend can scale the edit. const params: Record = {}; if ("strength" in op && typeof op.strength === "number") params.strength = op.strength; const res = await fetch(`${API}/edit`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ imageBase64: data, mimeType, op: wireOp, maskBase64, params }), }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(err.error || `AI request failed (${res.status}).`); } const { imageBase64, mimeType: outMime } = (await res.json()) as { imageBase64: string; mimeType?: string; }; return base64ToBlob(imageBase64, outMime || "image/png"); }, // Voice annotation: record → (optional denoise) → transcribe. Both proxy // through server routes so the RunPod key stays server-side. async transcribeAudio(audioBase64: string) { const res = await fetch(`${API}/transcribe`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ audio: audioBase64 }), }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(err.error || `Transcription failed (${res.status}).`); } const { transcript } = (await res.json()) as { transcript?: string }; return (transcript ?? "").trim(); }, async denoiseAudio(audioBase64: string) { const res = await fetch(`${API}/denoise`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ audio: audioBase64 }), }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(err.error || `Denoise failed (${res.status}).`); } const { audio } = (await res.json()) as { audio?: string }; return audio ?? audioBase64; }, // Camera-tilt estimation is opt-in (needs the RunPod tilt endpoint deployed); // gate it so the editor's Auto-straighten button only appears when configured. estimateTilt: useRunpodTilt ? async (_item: MediaItem, image: ImageSource) => { const { data } = imageToBase64(image, 1024); const res = await fetch(`${API}/tilt`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ image: data }), }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(err.error || `Tilt estimate failed (${res.status}).`); } return (await res.json()) as { rollDegrees: number; pitchDegrees: number; fovDegrees: number; }; } : undefined, }; }