feat(heal): add Hybrid Option 2 with Fast Engine and AI Server toggle
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
Reference in New Issue
Block a user