fix(studio): stroke clustering for HEAL, mobile WebGL inpaint stability, and reorganize LIGHT panels with HSL
This commit is contained in:
@@ -1300,6 +1300,54 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
});
|
||||
}
|
||||
|
||||
function clusterHealSpots(
|
||||
spots: { x: number; y: number; r: number }[],
|
||||
width: number,
|
||||
height: number
|
||||
): { x: number; y: number; r: number }[][] {
|
||||
if (spots.length <= 1) return spots.length ? [spots] : [];
|
||||
const n = spots.length;
|
||||
const adj: number[][] = Array.from({ length: n }, () => []);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const si = spots[i];
|
||||
const xi = si.x * width;
|
||||
const yi = si.y * height;
|
||||
const ri = si.r * width;
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const sj = spots[j];
|
||||
const xj = sj.x * width;
|
||||
const yj = sj.y * height;
|
||||
const rj = sj.r * width;
|
||||
const maxDist = (ri + rj) * 1.5;
|
||||
const distSq = (xi - xj) ** 2 + (yi - yj) ** 2;
|
||||
if (distSq <= maxDist * maxDist) {
|
||||
adj[i].push(j);
|
||||
adj[j].push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
const visited = new Uint8Array(n);
|
||||
const clusters: { x: number; y: number; r: number }[][] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (visited[i]) continue;
|
||||
visited[i] = 1;
|
||||
const cluster: { x: number; y: number; r: number }[] = [spots[i]];
|
||||
const queue = [i];
|
||||
while (queue.length > 0) {
|
||||
const curr = queue.pop()!;
|
||||
for (const neighbor of adj[curr]) {
|
||||
if (!visited[neighbor]) {
|
||||
visited[neighbor] = 1;
|
||||
cluster.push(spots[neighbor]);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
clusters.push(cluster);
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
// 6e. HEAL — the FX tab's dust brush (see heal.ts). It is the last thing
|
||||
// that touches the photo's own pixels, and deliberately so: the fill is made
|
||||
// of the pixels the pipeline has already built, so a repair matches the
|
||||
@@ -1424,6 +1472,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
// off on a smooth sky and the wrong colour outright on anything with shape.
|
||||
const box = Skia.XYWHRect(boxLeft, boxTop, boxRight - boxLeft, boxBottom - boxTop);
|
||||
const onto = Skia.XYWHRect(0, 0, boxRight - boxLeft, boxBottom - boxTop);
|
||||
fillCanvas.clear(Skia.Color('transparent'));
|
||||
if (cutImage) {
|
||||
for (let k = levelCount; k >= 1; k--) {
|
||||
const sigma = HEAL_FILL_BLUR * widest * 2 ** (k - 1);
|
||||
@@ -1438,91 +1487,86 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
|
||||
// The other half of the fill, and the better one where it can be had: the
|
||||
// pixels under the brush invented by MI-GAN from the pixels around them
|
||||
// (engine/inpaint.ts) rather than averaged from the rim. The blur above is
|
||||
// still built first and still covers the whole box — it is drawn over by
|
||||
// this where this reaches, and stands on its own wherever the model cannot
|
||||
// be had (no runtime, no adapter, a load that fails): a soft repair beats
|
||||
// no repair.
|
||||
// (engine/inpaint.ts) rather than averaged from the rim.
|
||||
//
|
||||
// What the model is handed is the mask's own box with a margin of context
|
||||
// around it, read back at the resolution the model will actually run at —
|
||||
// a repair is the mask and a few brush widths of what borders it, and the
|
||||
// pixels outside the mask are never read back out of the answer, so the
|
||||
// crop is cut to the mask and not to the frame. It is drawn into the fill
|
||||
// surface at its own place in the box, 1:1 whenever no downscale happened
|
||||
// and scaled back up when one did.
|
||||
const mx0 = Math.min(...xs) - widest;
|
||||
const my0 = Math.min(...ys) - widest;
|
||||
const mx1 = Math.max(...xs) + widest;
|
||||
const my1 = Math.max(...ys) + widest;
|
||||
const margin = Math.max(16, Math.min(64, Math.round(Math.max(widest * 1.5, 0.25 * Math.max(mx1 - mx0, my1 - my0)))));
|
||||
const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin));
|
||||
const srcTop = Math.max(boxTop, Math.floor(my0 - margin));
|
||||
const srcRight = Math.min(boxRight, Math.ceil(mx1 + margin));
|
||||
const srcBottom = Math.min(boxBottom, Math.ceil(my1 + margin));
|
||||
const srcW = srcRight - srcLeft;
|
||||
const srcH = srcBottom - srcTop;
|
||||
if (srcW >= 8 && srcH >= 8) {
|
||||
const { w: mw, h: mh } = inpaintCropSize(srcW, srcH);
|
||||
const cropSurface = own(createSurface(mw, mh));
|
||||
if (cropSurface) {
|
||||
cropSurface.getCanvas().drawImageRect(
|
||||
snap,
|
||||
Skia.XYWHRect(srcLeft, srcTop, srcW, srcH),
|
||||
Skia.XYWHRect(0, 0, mw, mh),
|
||||
own(Skia.Paint())
|
||||
);
|
||||
flush(cropSurface);
|
||||
const cropPixels = cropSurface.getCanvas().readPixels(0, 0, {
|
||||
width: mw,
|
||||
height: mh,
|
||||
colorType: Skia.ColorType.RGBA_8888,
|
||||
alphaType: Skia.AlphaType.Unpremul,
|
||||
colorSpace: Skia.ColorSpace.SRGB,
|
||||
});
|
||||
if (cropPixels) {
|
||||
// The brush's circles, at the crop's own resolution: the union of
|
||||
// the spots, hard-edged. Built from the numbers and not read back
|
||||
// out of `maskSurface`, because the two are not the same mask — the
|
||||
// feathered one is the *composite's* weight, and a fade is not
|
||||
// something the model has any use for. What the model wants is a
|
||||
// hole, and it is told about it the way MI-GAN reads it: non-zero
|
||||
// means keep (see inpaint.ts).
|
||||
const kx = mw / srcW;
|
||||
const ky = mh / srcH;
|
||||
const hole = new Uint8Array(mw * mh);
|
||||
for (const s of heal) {
|
||||
const cx = (s.x * width - srcLeft) * kx;
|
||||
const cy = (s.y * height - srcTop) * ky;
|
||||
const rad = s.r * width * kx;
|
||||
const x0 = Math.max(0, Math.floor(cx - rad));
|
||||
const x1 = Math.min(mw - 1, Math.ceil(cx + rad));
|
||||
const y0 = Math.max(0, Math.floor(cy - rad));
|
||||
const y1 = Math.min(mh - 1, Math.ceil(cy + rad));
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
const dy = y - cy;
|
||||
const row = y * mw;
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
const dx = x - cx;
|
||||
if (dx * dx + dy * dy <= rad * rad) hole[row + x] = 1;
|
||||
// Strokes are clustered so that separate brush repairs (e.g. Brush 1 and Brush 2)
|
||||
// are solved independently at their own high resolution rather than lumped into
|
||||
// one downscaled bounding box that would degrade previous repairs.
|
||||
const clusters = clusterHealSpots(heal, width, height);
|
||||
for (const cluster of clusters) {
|
||||
const cWidest = Math.max(...cluster.map((s) => s.r * width));
|
||||
const cXs = cluster.map((s) => s.x * width);
|
||||
const cYs = cluster.map((s) => s.y * height);
|
||||
const mx0 = Math.min(...cXs) - cWidest;
|
||||
const my0 = Math.min(...cYs) - cWidest;
|
||||
const mx1 = Math.max(...cXs) + cWidest;
|
||||
const my1 = Math.max(...cYs) + cWidest;
|
||||
const margin = Math.max(
|
||||
16,
|
||||
Math.min(64, Math.round(Math.max(cWidest * 1.5, 0.25 * Math.max(mx1 - mx0, my1 - my0))))
|
||||
);
|
||||
const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin));
|
||||
const srcTop = Math.max(boxTop, Math.floor(my0 - margin));
|
||||
const srcRight = Math.min(boxRight, Math.ceil(mx1 + margin));
|
||||
const srcBottom = Math.min(boxBottom, Math.ceil(my1 + margin));
|
||||
const srcW = srcRight - srcLeft;
|
||||
const srcH = srcBottom - srcTop;
|
||||
|
||||
if (srcW >= 8 && srcH >= 8) {
|
||||
const { w: mw, h: mh } = inpaintCropSize(srcW, srcH);
|
||||
const cropSurface = own(createSurface(mw, mh));
|
||||
if (cropSurface) {
|
||||
cropSurface.getCanvas().drawImageRect(
|
||||
snap,
|
||||
Skia.XYWHRect(srcLeft, srcTop, srcW, srcH),
|
||||
Skia.XYWHRect(0, 0, mw, mh),
|
||||
own(Skia.Paint())
|
||||
);
|
||||
flush(cropSurface);
|
||||
const cropPixels = cropSurface.getCanvas().readPixels(0, 0, {
|
||||
width: mw,
|
||||
height: mh,
|
||||
colorType: Skia.ColorType.RGBA_8888,
|
||||
alphaType: Skia.AlphaType.Unpremul,
|
||||
colorSpace: Skia.ColorSpace.SRGB,
|
||||
});
|
||||
if (cropPixels) {
|
||||
const kx = mw / srcW;
|
||||
const ky = mh / srcH;
|
||||
const hole = new Uint8Array(mw * mh);
|
||||
for (const s of cluster) {
|
||||
const cx = (s.x * width - srcLeft) * kx;
|
||||
const cy = (s.y * height - srcTop) * ky;
|
||||
const rad = s.r * width * kx;
|
||||
const x0 = Math.max(0, Math.floor(cx - rad));
|
||||
const x1 = Math.min(mw - 1, Math.ceil(cx + rad));
|
||||
const y0 = Math.max(0, Math.floor(cy - rad));
|
||||
const y1 = Math.min(mh - 1, Math.ceil(cy + rad));
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
const dy = y - cy;
|
||||
const row = y * mw;
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
const dx = x - cx;
|
||||
if (dx * dx + dy * dy <= rad * rad) hole[row + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const patch = await inpaintCrop(cropPixels, mw, mh, hole).catch(() => null);
|
||||
if (patch) {
|
||||
const patchSurface = own(createSurface(mw, mh));
|
||||
if (patchSurface && patchSurface.getCanvas().writePixels(patch, mw, mh, 0, 0)) {
|
||||
flush(patchSurface);
|
||||
const patchImage = own(patchSurface.makeImageSnapshot());
|
||||
if (patchImage) {
|
||||
const patchPaint = own(Skia.Paint());
|
||||
patchPaint.setAntiAlias(true);
|
||||
fillCanvas.drawImageRect(
|
||||
patchImage,
|
||||
Skia.XYWHRect(0, 0, mw, mh),
|
||||
Skia.XYWHRect(srcLeft - boxLeft, srcTop - boxTop, srcW, srcH),
|
||||
patchPaint
|
||||
);
|
||||
const patch = await inpaintCrop(cropPixels, mw, mh, hole).catch(() => null);
|
||||
if (patch) {
|
||||
const patchSurface = own(createSurface(mw, mh));
|
||||
if (patchSurface && patchSurface.getCanvas().writePixels(patch, mw, mh, 0, 0)) {
|
||||
flush(patchSurface);
|
||||
const patchImage = own(patchSurface.makeImageSnapshot());
|
||||
if (patchImage) {
|
||||
const patchPaint = own(Skia.Paint());
|
||||
patchPaint.setAntiAlias(true);
|
||||
fillCanvas.drawImageRect(
|
||||
patchImage,
|
||||
Skia.XYWHRect(0, 0, mw, mh),
|
||||
Skia.XYWHRect(srcLeft - boxLeft, srcTop - boxTop, srcW, srcH),
|
||||
patchPaint
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +97,12 @@ async function warmsUp(ort: typeof Ort, session: Ort.InferenceSession): Promise<
|
||||
}
|
||||
|
||||
// Whether the browser has a device at all.
|
||||
// WebGPU on iOS (iPhone / iPad / WKWebView) is experimental and unstable;
|
||||
// compiling WGSL or dispatching ONNX pipelines crashes WebKit's GPUProcess,
|
||||
// causing iOS to silently reload the page. iOS is therefore excluded from WebGPU.
|
||||
// Whether the browser has a device at all.
|
||||
// Mobile devices (Android Chrome and iOS WebKit) have unified memory architectures where
|
||||
// WebGPU buffer allocation destroys WebGL contexts (CanvasKit GrDirectContext loss -> black screen).
|
||||
// Therefore WebGPU is strictly restricted to desktop.
|
||||
async function hasDevice(): Promise<boolean> {
|
||||
if (isIOS()) return false;
|
||||
if (isMobile()) return false;
|
||||
try {
|
||||
return Boolean(await navigator.gpu?.requestAdapter());
|
||||
} catch {
|
||||
@@ -140,10 +141,8 @@ function load(): Promise<Loaded> {
|
||||
: self.crossOriginIsolated
|
||||
? Math.min(4, navigator.hardwareConcurrency || 1)
|
||||
: 1;
|
||||
// ALWAYS proxy ONNX WASM execution into a Web Worker.
|
||||
// Running heavy WASM inferences on the main thread locks the UI loop ("not responding")
|
||||
// and triggers browser watchdog termination (crash / full reload).
|
||||
ort.env.wasm.proxy = true;
|
||||
// On mobile, proxy=false avoids launching multiple WASM worker heaps and iOS Jetsam kills.
|
||||
ort.env.wasm.proxy = !mobile;
|
||||
ort.env.webgpu.powerPreference = 'high-performance';
|
||||
const session = await openSession(ort);
|
||||
return { ort, session };
|
||||
@@ -158,28 +157,17 @@ function load(): Promise<Loaded> {
|
||||
|
||||
// Start fetching the model before the brush that needs it, so the visitor waits
|
||||
// for the repair and not for the download. Called when HEAL is switched on.
|
||||
//
|
||||
// The bytes, and only the bytes: importing the runtime starts its thread pool —
|
||||
// a Web Worker each, a copy of the 27MB binary each — and a pool that then
|
||||
// belongs to the page until it dies, whether or not a stroke is ever painted.
|
||||
// Switching a tool on is not the work; the download is, and the browser's cache
|
||||
// is what keeps it.
|
||||
export function preloadInpaint(): void {
|
||||
if (isIOS()) return;
|
||||
for (const file of [...RUNTIME_FILES.map((f) => WASM_DIR + f), MODEL_URL]) {
|
||||
void fetch(file).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedPatch {
|
||||
key: string;
|
||||
patch: Uint8Array;
|
||||
}
|
||||
let patchCache: CachedPatch | null = null;
|
||||
const patchCache = new Map<string, Uint8Array>();
|
||||
let inFlightInpaint: Promise<Uint8Array | null> | null = null;
|
||||
|
||||
export function clearInpaintCache(): void {
|
||||
patchCache = null;
|
||||
patchCache.clear();
|
||||
}
|
||||
|
||||
// The page is going away, or coming back from the back/forward cache: hand the
|
||||
@@ -188,7 +176,7 @@ export function clearInpaintCache(): void {
|
||||
// back loads the runtime again instead of calling into a released session — the
|
||||
// files are cached, so that is a second.
|
||||
export async function releaseInpaint(): Promise<void> {
|
||||
patchCache = null;
|
||||
patchCache.clear();
|
||||
const pending = loaded;
|
||||
loaded = null;
|
||||
if (!pending) return;
|
||||
@@ -206,30 +194,12 @@ if (typeof addEventListener === 'function') {
|
||||
}
|
||||
|
||||
// The pixels under the brush, invented from the pixels around them.
|
||||
//
|
||||
// `rgba` is the crop the renderer cut out of the frame, at its own resolution;
|
||||
// `hole` is one byte per pixel, non-zero for the pixels the brush painted. The
|
||||
// answer is the same crop in RGBA at the same size, or null when the model could
|
||||
// not be had — the caller keeps its blur fill in that case, and a repair that is
|
||||
// merely soft beats no repair at all.
|
||||
//
|
||||
// A crop larger than INPAINT_MAX_SIDE on its longest side is scaled down to fit
|
||||
// and the answer is scaled back up by the caller. A brush stroke is a soft shape
|
||||
// made of soft shapes; what a factor of two of resample costs it is nothing
|
||||
// beside the seconds it saves.
|
||||
export async function inpaintCrop(
|
||||
rgba: Uint8Array | Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
hole: Uint8Array
|
||||
): Promise<Uint8Array | null> {
|
||||
// On iOS (iPhone/iPad WebKit), executing a 27MB deep learning model in WebAssembly
|
||||
// alongside CanvasKit exceeds the strict iOS per-tab Jetsam memory limit (~300MB),
|
||||
// causing iOS kernel to SIGKILL the WebProcess (tab crash / reload / "không thể mở trang này").
|
||||
// Returning null allows exportEngine to seamlessly use the hardware-accelerated Skia
|
||||
// multi-scale pyramid blur fill (step 6e), which flawlessly and seamlessly heals dirt/blemishes
|
||||
// in <5ms without any memory overhead or crashes.
|
||||
if (isIOS()) return null;
|
||||
|
||||
const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE;
|
||||
let w = Math.max(8, Math.round(width));
|
||||
@@ -259,8 +229,8 @@ export async function inpaintCrop(
|
||||
}
|
||||
const key = `${w}x${h}_${holeCount}_${holeHash}_${pixHash}`;
|
||||
|
||||
if (patchCache && patchCache.key === key) {
|
||||
return patchCache.patch.slice();
|
||||
if (patchCache.has(key)) {
|
||||
return patchCache.get(key)!.slice();
|
||||
}
|
||||
|
||||
// Single-flight lock: serialize inference executions to prevent WebAssembly memory spikes on mobile
|
||||
@@ -268,8 +238,8 @@ export async function inpaintCrop(
|
||||
try {
|
||||
await inFlightInpaint;
|
||||
} catch {}
|
||||
if (patchCache && patchCache.key === key) {
|
||||
return patchCache.patch.slice();
|
||||
if (patchCache.has(key)) {
|
||||
return patchCache.get(key)!.slice();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +294,11 @@ export async function inpaintCrop(
|
||||
for (const k in out) out[k]?.dispose?.();
|
||||
} catch {}
|
||||
|
||||
patchCache = { key, patch: patch.slice() };
|
||||
if (patchCache.size > 50) {
|
||||
const first = patchCache.keys().next().value;
|
||||
if (first) patchCache.delete(first);
|
||||
}
|
||||
patchCache.set(key, patch.slice());
|
||||
return patch;
|
||||
} catch (err) {
|
||||
// A model that cannot run is a soft repair, not a broken one — and this one
|
||||
|
||||
Reference in New Issue
Block a user