fix(studio): stroke clustering for HEAL, mobile WebGL inpaint stability, and reorganize LIGHT panels with HSL
This commit is contained in:
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user