import type { AIProvider } from '@photo-gallery/sdk'; import { createClipProvider } from './clipProvider'; import { createFaceProvider } from './faceProvider'; import { imageToBase64, maskToBase64 } from './imageEncode'; import { createOCRProvider } from './ocrProvider'; import { createRunpodYoloProvider } from './runpodYoloProvider'; import { createTensorflowProvider } from './tensorflowProvider'; /** * The demo's AI provider, combining free in-browser capabilities + a * server-proxied generative-edit backend (RunPod / local SD / Hugging Face / Gemini): * - object detection: TensorFlow.js COCO-SSD, fully in-browser (no key) * - face detection + recognition: face-api.js, in-browser → clustered into People * - OCR: tesseract.js, in-browser → searchable text + the Documents album (no key) * - background removal: @imgly, in-browser WASM (no key) — see generativeEdit * - other generative edits: proxied through /api/ai/edit (RunPod / local SD / * Hugging Face / Gemini, chosen by env) so the API key stays server-side. * * Object detection uses the in-browser COCO-SSD by default, or the RunPod YOLO * construction-material classifier (#1) when NEXT_PUBLIC_APG_RUNPOD_DETECT=true * (proxied through /api/ai/classify, with COCO-SSD as the automatic fallback). */ export function createDemoAIProvider(): AIProvider { const tf = createTensorflowProvider(); const face = createFaceProvider(); const ocr = createOCRProvider(); const clip = createClipProvider(); // Opt-in server-side detection; defaults to free in-browser COCO-SSD. const detectObjects = process.env.NEXT_PUBLIC_APG_RUNPOD_DETECT === 'true' ? createRunpodYoloProvider(tf).detectObjects : tf.detectObjects; return { name: 'demo-ai (coco-ssd/yolo + face-api + tesseract + clip + runpod-edit)', detectObjects, detectFaces: face.detectFaces, ocr: ocr.ocr, embedImage: clip.embedImage, embedText: clip.embedText, async generativeEdit(item, image, op) { // Remove Background runs fully in-browser (free, no key) via @imgly — works // even with no backend. Other ops go through the /api/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 (process.env.NEXT_PUBLIC_APG_RUNPOD_BG === 'true') { try { const { data, mimeType } = imageToBase64(image, 1600); const res = await fetch('/api/ai/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/ai/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); // SD 3.5 masked ops (#9) 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/ai/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) { const res = await fetch('/api/ai/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) { const res = await fetch('/api/ai/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: process.env.NEXT_PUBLIC_APG_RUNPOD_TILT === 'true' ? async (_item, image) => { const { data } = imageToBase64(image, 1024); const res = await fetch('/api/ai/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, }; } /** Draw an image to a canvas (downscaled) and return a JPEG Blob. */ function canvasBlob(image: ImageBitmap | HTMLImageElement, 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 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 {paddedImage, mask} * as base64 PNG — the border is WHITE in the mask (regenerate), the original * image area BLACK (keep). Capped at 1280px on the long side. */ function padForOutpaint( image: ImageBitmap | HTMLImageElement, 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 and it // leaves the border gray. Then draw the original sharp on top, centered. 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] ?? '', }; }