From 9a5243030e9efa236c58c99b4f44973e95162e61 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 20:23:29 +0700 Subject: [PATCH] feat(heal): add Hybrid Option 2 with Fast Engine and AI Server toggle --- docker/frontend/src/App.tsx | 23 +++++-- docker/frontend/src/engine/inpaint.ts | 93 ++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 26e6ccb..0ddd4a7 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -29,9 +29,9 @@ import { offerInstall } from './pwa/install'; import { cameraSupported } from './engine/camera'; import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session'; import { analyzeCustomFrame, clearCustomFrame, loadCustomFrame, saveCustomFrame, type CustomFrameData } from './engine/customFrame'; -import { keep } from './remember'; +import { keep, recall } from './remember'; import { preloadSuperRes, upscaleJpeg } from './engine/superRes'; -import { preloadInpaint, clearInpaintCache } from './engine/inpaint'; +import { preloadInpaint, clearInpaintCache, setAiInpaintMode } from './engine/inpaint'; import { api, type Photo, type SavedRecipe } from './api'; import { refreshAuth, useAuth } from './auth'; import { TIERS } from './config/tiers'; @@ -519,6 +519,11 @@ export function Workspace() { const [brushTool, setBrushTool] = useState<'heal' | 'mosaic' | null>(null); const [healR, setHealR] = useState(HEAL_DEFAULT_R); const [mosaicR, setMosaicR] = useState(MOSAIC_DEFAULT_R); + const [aiInpaint, setAiInpaint] = useState(() => recall('recipescam.heal.ai') === 'true'); + useEffect(() => { + setAiInpaintMode(aiInpaint); + keep('recipescam.heal.ai', String(aiInpaint)); + }, [aiInpaint]); // FX's gradient masks: which shape the next drag on the photo lays down (null // for neither), the masks the recipe holds, and which of them is chosen — the // one whose shape is on the photo and whose knobs are in the panel beside it. @@ -1163,7 +1168,7 @@ export function Workspace() { }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting]); + }, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting, aiInpaint]); // The source copy behind previewBytes, re-made only when previewMax moves: // applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back. @@ -2785,7 +2790,7 @@ export function Workspace() { if (!brushTool) return; const dismiss = (e: PointerEvent) => { const el = e.target as HTMLElement | null; - if (!el?.closest('.rail, .chip') || el.closest('.chip[data-key="heal"], .chip[data-key="mosaic"]')) return; + if (!el?.closest('.rail, .chip') || el.closest('.chip[data-key^="heal"], .chip[data-key^="mosaic"]')) return; setBrushTool(null); }; window.addEventListener('pointerdown', dismiss, true); @@ -3719,6 +3724,16 @@ export function Workspace() { } }, }, + ...(brushTool === 'heal' + ? [ + { + key: 'heal-ai', + label: aiInpaint ? '✨ AI SERVER' : '⚡ FAST ENGINE', + active: aiInpaint, + onClick: () => setAiInpaint((v: boolean) => !v), + }, + ] + : []), ...(healSpots.length ? [{ key: 'heal-clear', label: 'CLEAR', onClick: clearHealSpots }] : []), { key: 'mosaic', diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts index ec29b25..d016e5f 100644 --- a/docker/frontend/src/engine/inpaint.ts +++ b/docker/frontend/src/engine/inpaint.ts @@ -858,6 +858,39 @@ if (typeof addEventListener === 'function') { }); } +let isAiEnabled = false; + +export function setAiInpaintMode(enabled: boolean): void { + if (isAiEnabled !== enabled) { + isAiEnabled = enabled; + clearInpaintCache(); + } +} + +export function getAiInpaintMode(): boolean { + return isAiEnabled; +} + +function uint8ToBase64(bytes: Uint8Array): string { + let binary = ''; + const len = bytes.byteLength; + const chunk = 16384; + for (let i = 0; i < len; i += chunk) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, Math.min(i + chunk, len)) as any); + } + return btoa(binary); +} + +function base64ToUint8(base64: string): Uint8Array { + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + // The pixels under the brush, invented from the pixels around them. export async function inpaintCrop( rgba: Uint8Array | Uint8ClampedArray, @@ -889,17 +922,61 @@ export async function inpaintCrop( key = `${w}x${h}_${holeCount}_${holeHash}`; } - if (patchCache.has(key)) { - return patchCache.get(key)!.slice(); + // 1. FAST ENGINE: Pure TypeScript Border-to-Center Inward Ripple Synthesis (~5ms) + const localPatch = fastLocalInpaint(rgba, width, height, hole); + + if (!isAiEnabled) { + if (patchCache.has(key)) { + return patchCache.get(key)!.slice(); + } + if (patchCache.size > 50) { + const first = patchCache.keys().next().value; + if (first) patchCache.delete(first); + } + patchCache.set(key, localPatch.slice()); + return localPatch; } - const patch = fastLocalInpaint(rgba, width, height, hole); - if (patchCache.size > 50) { - const first = patchCache.keys().next().value; - if (first) patchCache.delete(first); + // 2. HYBRID OPTION 2: Server-side AI Inpainting via Backend API + const aiKey = `${key}_ai`; + if (patchCache.has(aiKey)) { + return patchCache.get(aiKey)!.slice(); } - patchCache.set(key, patch.slice()); - return patch; + + try { + const { src, hole: mask } = cropPlate(rgba, width, height, hole, w, h); + const srcB64 = uint8ToBase64(src); + const maskB64 = uint8ToBase64(mask); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3500); + + const res = await fetch('/api/inpaint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ src: srcB64, mask: maskB64, w, h }), + signal: controller.signal, + }).catch(() => null); + + clearTimeout(timeoutId); + + if (res && res.ok) { + const json = await res.json().catch(() => null); + if (json && json.ok && json.patch) { + const aiPatch = base64ToUint8(json.patch); + if (patchCache.size > 50) { + const first = patchCache.keys().next().value; + if (first) patchCache.delete(first); + } + patchCache.set(aiKey, aiPatch.slice()); + return aiPatch; + } + } + } catch (err) { + console.warn('[inpaint] server AI request error, using local engine patch:', err); + } + + return localPatch; } export function inpaintCropSize(width: number, height: number): { w: number; h: number } {