fix(studio): stroke clustering for HEAL, mobile WebGL inpaint stability, and reorganize LIGHT panels with HSL

This commit is contained in:
2026-10-14 12:57:31 +07:00
parent c1d098e4e2
commit 70ed61b8de
6 changed files with 333 additions and 222 deletions
+1 -1
View File
@@ -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);
}
`;
+175 -85
View File
@@ -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() {
<ChipRow chips={choiceChips('dr', DR_OPTIONS, String(recipe.adjustments.dynamicRange ?? 'auto'), groupDefs.dr.onPick)} />
</div>
),
hsl: (
<div className="dev-box" data-key="dev-box-hsl" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px' }}>
<button
type="button"
className={`chip${picking ? ' on' : ''}`}
onClick={() => {
setBrushTool(null);
setMaskTool(null);
setPicking((v) => !v);
}}
>
<span className="glyph">⌖</span> PICK
</button>
{sample && picked ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--text-dim)' }}>
<span
style={{
display: 'inline-block',
width: '12px',
height: '12px',
borderRadius: '50%',
background: `rgb(${sample.r}, ${sample.g}, ${sample.b})`,
border: '1px solid var(--border)',
}}
/>
<span>R {sample.r} · G {sample.g} · B {sample.b}</span>
</div>
) : null}
</div>
<div>
<span className="dev-box-title" style={{ marginBottom: '6px', display: 'block' }}>COLOR BANDS</span>
<ChipRow
grid
chips={HSL_BANDS.map((b) => {
const band = recipe.adjustments.hslBands?.[b.id];
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
return {
key: `dev-hsl-${b.id}`,
label: b.label,
color: hslToHex(b.hue, 70, 50),
active: hslBand === b.id,
amberValue: hslBand !== b.id && moved,
onClick: () => {
setHslBand(b.id);
setSample(hexToRgb(hslToHex(b.hue, 70, 50)));
setPickedAt((at) => at ?? { fx: 0.5, fy: 0.5 });
},
};
})}
/>
</div>
{hslBand && (
<div className="dev-subgroup" style={{ marginTop: '4px' }}>
<div className="dev-subgroup-title" style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>BAND: {HSL_BANDS.find((b) => b.id === hslBand)?.label}</span>
{(recipe.adjustments.hslBands?.[hslBand]?.[0] || recipe.adjustments.hslBands?.[hslBand]?.[1] || recipe.adjustments.hslBands?.[hslBand]?.[2]) ? (
<button
type="button"
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '10px' }}
onClick={() => {
remember();
const bands = { ...(recipe.adjustments.hslBands ?? {}) };
delete bands[hslBand];
setAdjustment({ hslBands: bands });
}}
>
RESET
</button>
) : null}
</div>
{(() => {
const bandVals = recipe.adjustments.hslBands?.[hslBand] ?? [0, 0, 0];
const updateBand = (idx: number, val: number) => {
remember();
const cur = [...(recipe.adjustments.hslBands?.[hslBand] ?? [0, 0, 0])] as [number, number, number];
cur[idx] = val;
setAdjustment({
hslBands: {
...(recipe.adjustments.hslBands ?? {}),
[hslBand]: cur,
},
});
};
return (
<>
<div className="dev-row">
<span className="dev-row-label">Hue</span>
<input
type="range"
min={-30}
max={30}
value={bandVals[0]}
onChange={(e) => updateBand(0, Number(e.target.value))}
/>
<span className="dev-row-value">{bandVals[0] > 0 ? `+${bandVals[0]}` : bandVals[0]}</span>
</div>
<div className="dev-row">
<span className="dev-row-label">Saturation</span>
<input
type="range"
min={-100}
max={100}
value={bandVals[1]}
onChange={(e) => updateBand(1, Number(e.target.value))}
/>
<span className="dev-row-value">{bandVals[1] > 0 ? `+${bandVals[1]}` : bandVals[1]}</span>
</div>
<div className="dev-row">
<span className="dev-row-label">Luminance</span>
<input
type="range"
min={-100}
max={100}
value={bandVals[2]}
onChange={(e) => updateBand(2, Number(e.target.value))}
/>
<span className="dev-row-value">{bandVals[2] > 0 ? `+${bandVals[2]}` : bandVals[2]}</span>
</div>
</>
);
})()}
</div>
)}
<div className="dev-subgroup" style={{ marginTop: '4px' }}>
<div className="dev-subgroup-title">IMAGE MASTER</div>
<div className="dev-row">
<span className="dev-row-label">Hue</span>
<input
type="range"
min={-180}
max={180}
value={recipe.adjustments.hslHue ?? 0}
onChange={(e) => {
remember();
setAdjustment({ hslHue: Number(e.target.value) });
}}
/>
<span className="dev-row-value">{(recipe.adjustments.hslHue ?? 0) > 0 ? `+${recipe.adjustments.hslHue}` : recipe.adjustments.hslHue ?? 0}</span>
</div>
<div className="dev-row">
<span className="dev-row-label">Saturation</span>
<input
type="range"
min={-100}
max={100}
value={recipe.adjustments.hslSat ?? 0}
onChange={(e) => {
remember();
setAdjustment({ hslSat: Number(e.target.value) });
}}
/>
<span className="dev-row-value">{(recipe.adjustments.hslSat ?? 0) > 0 ? `+${recipe.adjustments.hslSat}` : recipe.adjustments.hslSat ?? 0}</span>
</div>
<div className="dev-row">
<span className="dev-row-label">Luminance</span>
<input
type="range"
min={-100}
max={100}
value={recipe.adjustments.hslLum ?? 0}
onChange={(e) => {
remember();
setAdjustment({ hslLum: Number(e.target.value) });
}}
/>
<span className="dev-row-value">{(recipe.adjustments.hslLum ?? 0) > 0 ? `+${recipe.adjustments.hslLum}` : recipe.adjustments.hslLum ?? 0}</span>
</div>
</div>
</div>
),
};
// 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 ? <ColGrip col="main" /> : null}
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : 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 ? (
<div className="hsl-readout" data-key="hsl-readout">
{sample && picked ? (
<>
<span
className="hsl-swatch"
data-key="hsl-swatch"
data-color={`rgb(${sample.r}, ${sample.g}, ${sample.b})`}
style={{ background: `rgb(${sample.r}, ${sample.g}, ${sample.b})` }}
/>
<span className="hsl-line" data-key="hsl-rgb">
R {sample.r} · G {sample.g} · B {sample.b}
</span>
<span className="hsl-line" data-key="hsl-hsl">
{Math.round(picked.h)}° · {Math.round(picked.s * 100)}% · {Math.round(picked.l * 100)}%
</span>
<span className="hsl-line" data-key="hsl-band">
{HSL_BANDS.find((b) => b.id === hslBand)?.label}
</span>
</>
) : (
<p className="hint">{t('hsl.hint')}</p>
)}
</div>
) : 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. */}
<div className="reset-row">
+126 -82
View File
@@ -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
);
}
}
}
}
+19 -45
View File
@@ -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
+11 -6
View File
@@ -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<ColorAdjustments>) => 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;
}) {
+1 -3
View File
@@ -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