feat(heal): add Hybrid Option 2 with Fast Engine and AI Server toggle

This commit is contained in:
2026-10-16 20:23:29 +07:00
parent 8aa59bcfb2
commit 9a5243030e
2 changed files with 104 additions and 12 deletions
+19 -4
View File
@@ -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<boolean>(() => 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',
+85 -8
View File
@@ -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 } {