diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts
index bf12575..400d768 100644
--- a/docker/frontend/shared/utils/heal.ts
+++ b/docker/frontend/shared/utils/heal.ts
@@ -109,7 +109,7 @@ half4 main(float2 pos) {
if (m <= 0.0) return c;
half4 f = fill.eval(pos);
half3 here = c.a > 0.0001 ? c.rgb / c.a : c.rgb;
- half3 made = f.a > 0.0001 ? f.rgb / f.a : here;
+ half3 made = f.a > 0.05 ? f.rgb / f.a : here;
return half4(mix(here, made, m) * c.a, c.a);
}
`;
diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx
index 468938d..54f80ea 100644
--- a/docker/frontend/src/App.tsx
+++ b/docker/frontend/src/App.tsx
@@ -3616,64 +3616,6 @@ export function Workspace() {
{ key: 'tools-rule', divider: true, label: '', onClick: () => {} },
...shapeChips,
];
- case 'hsl': {
- // The mixer is PRO: the picker, the eight bands and the three
- // image-wide knobs all come with the account, so a guest is offered the
- // reason rather than a wall of dead chips.
- if (!pro) return [{ key: 'hsl-pro', label: t('pro.hslChip'), pro: true, onClick: promptPro }];
- // PICK arms the eyedropper; the eight band chips choose which band the
- // ruler edits, and the ruler itself is opened by the three knobs below.
- // Those three are the image's own move, not the band's: the band chips
- // pick which colour the panel on the photo edits, while HUE/SAT/LUM
- // below the divider move every hue in the frame at once.
- const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0];
- const knob = (key: string, label: string, i: number): ChipDef => ({
- key,
- label,
- value: own[i] > 0 ? `+${own[i]}` : String(own[i]),
- active: openParam === key,
- amberValue: openParam !== key && own[i] !== 0,
- onClick: () => toggleParam(key),
- });
- return [
- { key: 'hsl-pick', label: '⌖ PICK', active: picking, onClick: () => { setBrushTool(null); setMaskTool(null); setPicking((v) => !v); } },
- // PICK is not a band: it reads a colour off the photo to aim the
- // mixer, and every chip under the rule edits what it read, so the
- // rule is where the row stops being the tool and starts being the
- // colour.
- { key: 'hsl-pick-rule', divider: true, label: '', onClick: () => {} },
- ...HSL_BANDS.map((b): ChipDef => {
- const band = recipe.adjustments.hslBands?.[b.id];
- const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
- return {
- key: `hsl-band-${b.id}`,
- label: b.label,
- // The chip names a colour, so it shows the colour it names.
- color: hslToHex(b.hue, 70, 50),
- active: hslBand === b.id,
- amberValue: hslBand !== b.id && moved,
- // A band chip aims the mixer AND opens its panel, the same panel
- // the eyedropper opens: the colour it starts from is the colour
- // the chip is named after, and this band's knobs push it from
- // there. A pick already on the photo keeps its spot — only a
- // mixer with nowhere to hang takes the middle of the frame.
- onClick: () => {
- setHslBand(b.id);
- setSample(hexToRgb(hslToHex(b.hue, 70, 50)));
- setPickedAt((at) => at ?? { fx: 0.5, fy: 0.5 });
- },
- };
- }),
- // The divider keeps the three image-wide sliders out of the colour
- // row: the rule stands where the colour ends and the knobs that move
- // every colour at once begin, and IMAGE names what they move.
- { key: 'hsl-image-rule', divider: true, label: '', onClick: () => {} },
- { key: 'hsl-image', label: 'IMAGE', disabled: true, onClick: () => {} },
- knob('hsl.h', 'HUE', 0),
- knob('hsl.s', 'SAT', 1),
- knob('hsl.l', 'LUM', 2),
- ];
- }
case 'frame':
// WATERMARK is a chip like the rest: its own sub-panel opens as the next
// column (see the wm column in the render), so the frame chips stay put.
@@ -3997,6 +3939,180 @@ export function Workspace() {
),
+ hsl: (
+
+ ),
};
// The panel is a cascade of columns (see styles/app.css): the tab's chips, the
@@ -4290,33 +4406,7 @@ export function Workspace() {
their column is the visitor's to widen (see ColGrip). */}
{listTab ? : null}
{tab === 'favorited' && saved.length === 0 ?
{t('sec.savedEmpty')}
: null}
- {/* The mixer's readout: the colour the eyedropper last read, as the
- RGB bytes and the HSL the band is indexed by. */}
- {tab === 'hsl' && pro ? (
-
- ) : null}
+
{/* RESET is the way out of an edit, not one more chip: it sits under
a rule at the foot of the column so it reads as its own thing. */}
diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts
index 23a89a9..f64ba2f 100644
--- a/docker/frontend/src/engine/exportEngine.ts
+++ b/docker/frontend/src/engine/exportEngine.ts
@@ -1300,6 +1300,54 @@ export async function renderPhoto(input: RenderInput): Promise []);
+ 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= 1; k--) {
const sigma = HEAL_FILL_BLUR * widest * 2 ** (k - 1);
@@ -1438,91 +1487,86 @@ export async function renderPhoto(input: RenderInput): Promise= 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
+ );
+ }
}
}
}
diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts
index 3e1853b..a10b9ad 100644
--- a/docker/frontend/src/engine/inpaint.ts
+++ b/docker/frontend/src/engine/inpaint.ts
@@ -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 {
- if (isIOS()) return false;
+ if (isMobile()) return false;
try {
return Boolean(await navigator.gpu?.requestAdapter());
} catch {
@@ -140,10 +141,8 @@ function load(): Promise {
: 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 {
// 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();
let inFlightInpaint: Promise | 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 {
- 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 {
- // 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
diff --git a/docker/frontend/src/ui/DevelopPanels.tsx b/docker/frontend/src/ui/DevelopPanels.tsx
index 6dda390..85180dd 100644
--- a/docker/frontend/src/ui/DevelopPanels.tsx
+++ b/docker/frontend/src/ui/DevelopPanels.tsx
@@ -47,7 +47,7 @@ const PANELS: {
rows: string[];
}[];
rows?: string[];
- slot?: 'wb' | 'effects';
+ slot?: 'wb' | 'effects' | 'hsl';
foot?: 'wbFoot' | 'toneFoot';
slotFirst?: boolean;
}[] = [
@@ -65,6 +65,12 @@ const PANELS: {
},
],
},
+ {
+ slug: 'wb',
+ title: 'White balance',
+ slot: 'wb',
+ foot: 'wbFoot',
+ },
{
slug: 'creative',
title: 'Creative',
@@ -78,12 +84,11 @@ const PANELS: {
{
slug: 'wheels',
title: 'Color Wheels & Match',
- slot: 'wb',
- foot: 'wbFoot',
+ slot: 'hsl',
},
{
- slug: 'hsl',
- title: 'HSL Secondary',
+ slug: 'details',
+ title: 'Details',
slot: 'effects',
rows: ['denoise', 'sharpening'],
},
@@ -193,7 +198,7 @@ export function DevelopPanels({
set: (patch: Partial) => void;
pro?: boolean;
onPro?: () => void;
- slots?: { wb?: ReactNode; wbFoot?: ReactNode; toneFoot?: ReactNode; effects?: ReactNode };
+ slots?: { wb?: ReactNode; wbFoot?: ReactNode; toneFoot?: ReactNode; effects?: ReactNode; hsl?: ReactNode };
baseFilter?: string;
onBaseFilterChange?: (filter: string) => void;
}) {
diff --git a/docker/frontend/src/ui/ToolRail.tsx b/docker/frontend/src/ui/ToolRail.tsx
index 6453a70..a5796fc 100644
--- a/docker/frontend/src/ui/ToolRail.tsx
+++ b/docker/frontend/src/ui/ToolRail.tsx
@@ -10,7 +10,7 @@ import { useI18n } from '../i18n/I18nProvider';
// rather than pick a look for it — CROP, ROTATE and WATERMARK, which used to
// close the frame row. The rail is a single column on
// desktop and becomes the bottom bar under 860px (see styles/app.css).
-export type TabId = 'presets' | 'favorited' | 'photos' | 'light' | 'tools' | 'hsl' | 'frame' | 'save' | 'create';
+export type TabId = 'presets' | 'favorited' | 'photos' | 'light' | 'tools' | 'frame' | 'save' | 'create';
// The build this bundle came from, put in by vite at build time and named by
// whoever built the image (see vite.config.ts). Empty under a dev server.
@@ -24,8 +24,6 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
// The hammer and pick: the features that work ON the photo — AUTO's reading,
// FIX's brushes, GRADIENT MASK's shapes, the MONOCHROME switch.
{ id: 'tools', label: 'tab.tools', glyph: '⚒' },
- // The HSL mixer sits with the colour tabs: it edits one hue band at a time.
- { id: 'hsl', label: 'tab.hsl', glyph: '◍' },
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
// Keeping the look on the stage and building one from scratch are two acts,
// so they get two tabs: SAVE RECENT files what is open, CREATE RECIPES starts