From 70ed61b8def4f79980949cf799b9e9eb98830400 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 14 Oct 2026 12:57:31 +0700 Subject: [PATCH] fix(studio): stroke clustering for HEAL, mobile WebGL inpaint stability, and reorganize LIGHT panels with HSL --- docker/frontend/shared/utils/heal.ts | 2 +- docker/frontend/src/App.tsx | 260 ++++++++++++++------- docker/frontend/src/engine/exportEngine.ts | 208 ++++++++++------- docker/frontend/src/engine/inpaint.ts | 64 ++--- docker/frontend/src/ui/DevelopPanels.tsx | 17 +- docker/frontend/src/ui/ToolRail.tsx | 4 +- 6 files changed, 333 insertions(+), 222 deletions(-) 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: ( +
+
+ + {sample && picked ? ( +
+ + R {sample.r} · G {sample.g} · B {sample.b} +
+ ) : null} +
+ +
+ COLOR BANDS + { + 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 }); + }, + }; + })} + /> +
+ + {hslBand && ( +
+
+ BAND: {HSL_BANDS.find((b) => b.id === hslBand)?.label} + {(recipe.adjustments.hslBands?.[hslBand]?.[0] || recipe.adjustments.hslBands?.[hslBand]?.[1] || recipe.adjustments.hslBands?.[hslBand]?.[2]) ? ( + + ) : null} +
+ {(() => { + 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 ( + <> +
+ Hue + updateBand(0, Number(e.target.value))} + /> + {bandVals[0] > 0 ? `+${bandVals[0]}` : bandVals[0]} +
+
+ Saturation + updateBand(1, Number(e.target.value))} + /> + {bandVals[1] > 0 ? `+${bandVals[1]}` : bandVals[1]} +
+
+ Luminance + updateBand(2, Number(e.target.value))} + /> + {bandVals[2] > 0 ? `+${bandVals[2]}` : bandVals[2]} +
+ + ); + })()} +
+ )} + +
+
IMAGE MASTER
+
+ Hue + { + remember(); + setAdjustment({ hslHue: Number(e.target.value) }); + }} + /> + {(recipe.adjustments.hslHue ?? 0) > 0 ? `+${recipe.adjustments.hslHue}` : recipe.adjustments.hslHue ?? 0} +
+
+ Saturation + { + remember(); + setAdjustment({ hslSat: Number(e.target.value) }); + }} + /> + {(recipe.adjustments.hslSat ?? 0) > 0 ? `+${recipe.adjustments.hslSat}` : recipe.adjustments.hslSat ?? 0} +
+
+ Luminance + { + remember(); + setAdjustment({ hslLum: Number(e.target.value) }); + }} + /> + {(recipe.adjustments.hslLum ?? 0) > 0 ? `+${recipe.adjustments.hslLum}` : recipe.adjustments.hslLum ?? 0} +
+
+
+ ), }; // 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 ? ( -
- {sample && picked ? ( - <> - - - R {sample.r} · G {sample.g} · B {sample.b} - - - {Math.round(picked.h)}° · {Math.round(picked.s * 100)}% · {Math.round(picked.l * 100)}% - - - {HSL_BANDS.find((b) => b.id === hslBand)?.label} - - - ) : ( -

{t('hsl.hint')}

- )} -
- ) : 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