diff --git a/docker/frontend/scripts/copy-wasm.mjs b/docker/frontend/scripts/copy-wasm.mjs index ffcb436..4612f15 100644 --- a/docker/frontend/scripts/copy-wasm.mjs +++ b/docker/frontend/scripts/copy-wasm.mjs @@ -18,6 +18,14 @@ const copies = [ 'node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.mjs', 'public/wasm/ort/ort-wasm-simd-threaded.jsep.mjs', ], + [ + 'node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.wasm', + 'public/wasm/ort/ort-wasm-simd-threaded.wasm', + ], + [ + 'node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.mjs', + 'public/wasm/ort/ort-wasm-simd-threaded.mjs', + ], ]; for (const [from, to] of copies) { diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index e4d49d6..468938d 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -29,7 +29,7 @@ import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, import { analyzeCustomFrame, clearCustomFrame, loadCustomFrame, saveCustomFrame, type CustomFrameData } from './engine/customFrame'; import { keep } from './remember'; import { preloadSuperRes, upscaleJpeg } from './engine/superRes'; -import { preloadInpaint } from './engine/inpaint'; +import { preloadInpaint, clearInpaintCache } from './engine/inpaint'; import { api, type Photo, type SavedRecipe } from './api'; import { refreshAuth, useAuth } from './auth'; import { TIERS } from './config/tiers'; @@ -1515,6 +1515,7 @@ export function Workspace() { ); const clearHealSpots = useCallback(() => { remember(); + clearInpaintCache(); // The field is what "this photo was healed" means, so CLEAR drops it rather // than leaving an empty list behind. setAdjustment({ heal: undefined }); @@ -1563,6 +1564,7 @@ export function Workspace() { const editBrushSpots = useCallback( (spots: (HealSpot | MosaicSpot)[], undo: boolean) => { if (undo) remember(); + clearInpaintCache(); const list = spots.length ? spots : undefined; if (brushTool === 'mosaic') setAdjustment({ mosaic: list as MosaicSpot[] | undefined }); else setAdjustment({ heal: list as HealSpot[] | undefined }); @@ -4272,6 +4274,11 @@ export function Workspace() { pro={pro} onPro={promptPro} slots={developSlots} + baseFilter={recipe.baseFilter} + onBaseFilterChange={(filter) => { + remember(); + setRecipe((prev) => ({ ...prev, baseFilter: filter as Recipe['baseFilter'], isCustom: true })); + }} /> ) : ( diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts index 3dd9866..b9d0ed4 100644 --- a/docker/frontend/src/engine/inpaint.ts +++ b/docker/frontend/src/engine/inpaint.ts @@ -50,11 +50,20 @@ interface Loaded { let loaded: Promise | null = null; +function isMobile(): boolean { + if (typeof navigator === 'undefined') return false; + const ua = navigator.userAgent || ''; + return ( + /iPad|iPhone|iPod|Android|Mobile/i.test(ua) || + (navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1) + ); +} + function isIOS(): boolean { if (typeof navigator === 'undefined') return false; const ua = navigator.userAgent || ''; return ( - /iPad|iPhone|iPod/.test(ua) || + /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1) ); } @@ -123,16 +132,18 @@ function load(): Promise { loaded = (async () => { const ort = await import('onnxruntime-web'); ort.env.wasm.wasmPaths = WASM_DIR; - const ios = isIOS(); - // On iOS WebKit, multi-threading or worker proxying triggers memory limits - // and WebKit crashes. Single thread direct WASM execution is rock-solid and - // fast on Apple Silicon / A-series chips. - ort.env.wasm.numThreads = ios + const mobile = isMobile(); + // On mobile devices, 1 worker thread keeps memory strictly under the browser tab limit. + // On desktop, up to 4 threads if cross-origin isolated. + ort.env.wasm.numThreads = mobile ? 1 : self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency || 1) : 1; - ort.env.wasm.proxy = !ios; + // 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; ort.env.webgpu.powerPreference = 'high-performance'; const session = await openSession(ort); return { ort, session }; @@ -159,12 +170,24 @@ export function preloadInpaint(): void { } } +interface CachedPatch { + key: string; + patch: Uint8Array; +} +let patchCache: CachedPatch | null = null; +let inFlightInpaint: Promise | null = null; + +export function clearInpaintCache(): void { + patchCache = null; +} + // The page is going away, or coming back from the back/forward cache: hand the // session back rather than leaving the browser to reap its buffers and its // thread pool with the page. `loaded` goes with it, so a page that does come // 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; const pending = loaded; loaded = null; if (!pending) return; @@ -199,7 +222,7 @@ export async function inpaintCrop( height: number, hole: Uint8Array ): Promise { - const maxSide = isIOS() ? 384 : INPAINT_MAX_SIDE; + const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE; let w = Math.max(8, Math.round(width)); let h = Math.max(8, Math.round(height)); const scale = Math.min(1, maxSide / Math.max(w, h)); @@ -210,61 +233,106 @@ export async function inpaintCrop( w = Math.max(8, Math.floor((w * scale) / 8) * 8); h = Math.max(8, Math.floor((h * scale) / 8) * 8); - // NCHW, 0..255 RGB — what the model was trained to read — and the mask in its - // own polarity: 255 everywhere the frame is known, 0 over the hole. - const src = new Uint8Array(3 * w * h); - const mask = new Uint8Array(w * h).fill(255); - const sx = width / w; - const sy = height / h; - const plane = w * h; - for (let y = 0; y < h; y++) { - // Nearest source pixel rather than a box filter: the crop is the frame at - // its own resolution whenever it fits, so the two agree pixel for pixel and - // this loop never runs at all in the common case. - const syy = Math.min(height - 1, Math.floor(y * sy)); - for (let x = 0; x < w; x++) { - const sxx = Math.min(width - 1, Math.floor(x * sx)); - const s = (syy * width + sxx) * 4; - const d = y * w + x; - src[d] = rgba[s]; - src[plane + d] = rgba[s + 1]; - src[2 * plane + d] = rgba[s + 2]; - if (hole[syy * width + sxx]) mask[d] = 0; + // Quick signature to avoid redundant re-inferences on subsequent renders + let holeCount = 0; + let holeHash = 0; + const step = Math.max(1, Math.floor(hole.length / 64)); + for (let i = 0; i < hole.length; i += step) { + if (hole[i]) { + holeCount++; + holeHash = (holeHash * 31 + i) | 0; + } + } + let pixHash = 0; + const pixStep = Math.max(4, Math.floor(rgba.length / 64) * 4); + for (let i = 0; i < rgba.length; i += pixStep) { + pixHash = (pixHash * 33 + rgba[i] + rgba[i + 1]) | 0; + } + const key = `${w}x${h}_${holeCount}_${holeHash}_${pixHash}`; + + if (patchCache && patchCache.key === key) { + return patchCache.patch.slice(); + } + + // Single-flight lock: serialize inference executions to prevent WebAssembly memory spikes on mobile + if (inFlightInpaint) { + try { + await inFlightInpaint; + } catch {} + if (patchCache && patchCache.key === key) { + return patchCache.patch.slice(); } } - try { - const { ort, session } = await load(); - const in0 = new ort.Tensor('uint8', src, [1, 3, h, w]); - const in1 = new ort.Tensor('uint8', mask, [1, 1, h, w]); - const out = await session.run({ - [session.inputNames[0]]: in0, - [session.inputNames[1]]: in1, - }); - const tensor = out[session.outputNames[0]]; - const oplane = w * h; - const data = tensor.data as Uint8Array; - // Back to RGBA, opaque: the crop covers the frame, which has no transparency - // of its own to carry. - const patch = new Uint8Array(4 * w * h); - for (let i = 0; i < oplane; i++) { - patch[i * 4] = data[i]; - patch[i * 4 + 1] = data[oplane + i]; - patch[i * 4 + 2] = data[2 * oplane + i]; - patch[i * 4 + 3] = 255; + const runPromise = (async (): Promise => { + // NCHW, 0..255 RGB — what the model was trained to read — and the mask in its + // own polarity: 255 everywhere the frame is known, 0 over the hole. + const src = new Uint8Array(3 * w * h); + const mask = new Uint8Array(w * h).fill(255); + const sx = width / w; + const sy = height / h; + const plane = w * h; + for (let y = 0; y < h; y++) { + // Nearest source pixel rather than a box filter: the crop is the frame at + // its own resolution whenever it fits, so the two agree pixel for pixel and + // this loop never runs at all in the common case. + const syy = Math.min(height - 1, Math.floor(y * sy)); + for (let x = 0; x < w; x++) { + const sxx = Math.min(width - 1, Math.floor(x * sx)); + const s = (syy * width + sxx) * 4; + const d = y * w + x; + src[d] = rgba[s]; + src[plane + d] = rgba[s + 1]; + src[2 * plane + d] = rgba[s + 2]; + if (hole[syy * width + sxx]) mask[d] = 0; + } } + try { - in0.dispose?.(); - in1.dispose?.(); - tensor.dispose?.(); - } catch {} - return patch; - } catch (err) { - // A model that cannot run is a soft repair, not a broken one — and this one - // line is what keeps the fallback diagnosable instead of invisible, because - // the picture that comes out of it looks like a workable repair. - console.warn('[inpaint] model unavailable, keeping the blurred fill', err); - return null; + const { ort, session } = await load(); + const in0 = new ort.Tensor('uint8', src, [1, 3, h, w]); + const in1 = new ort.Tensor('uint8', mask, [1, 1, h, w]); + const out = await session.run({ + [session.inputNames[0]]: in0, + [session.inputNames[1]]: in1, + }); + const tensor = out[session.outputNames[0]]; + const oplane = w * h; + const data = tensor.data as Uint8Array; + // Back to RGBA, opaque: the crop covers the frame, which has no transparency + // of its own to carry. + const patch = new Uint8Array(4 * w * h); + for (let i = 0; i < oplane; i++) { + patch[i * 4] = data[i]; + patch[i * 4 + 1] = data[oplane + i]; + patch[i * 4 + 2] = data[2 * oplane + i]; + patch[i * 4 + 3] = 255; + } + try { + in0.dispose?.(); + in1.dispose?.(); + tensor.dispose?.(); + for (const k in out) out[k]?.dispose?.(); + } catch {} + + patchCache = { key, patch: patch.slice() }; + return patch; + } catch (err) { + // A model that cannot run is a soft repair, not a broken one — and this one + // line is what keeps the fallback diagnosable instead of invisible, because + // the picture that comes out of it looks like a workable repair. + console.warn('[inpaint] model unavailable, keeping the blurred fill', err); + return null; + } + })(); + + inFlightInpaint = runPromise; + try { + return await runPromise; + } finally { + if (inFlightInpaint === runPromise) { + inFlightInpaint = null; + } } } @@ -273,7 +341,7 @@ export async function inpaintCrop( // uses it to cut the crop it hands over: a 3000px box read back at 3000px and // scaled here would be 27MB of pixels moved for a repair that ends up 512 wide. export function inpaintCropSize(width: number, height: number): { w: number; h: number } { - const maxSide = isIOS() ? 384 : INPAINT_MAX_SIDE; + const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE; const scale = Math.min(1, maxSide / Math.max(1, Math.max(width, height))); return { w: Math.max(8, Math.floor((Math.max(1, Math.round(width)) * scale) / 8) * 8), diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 2e18730..caf5957 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1848,31 +1848,249 @@ form[data-key='immich-modal'] { one: the same width CREATE RECIPES takes, and the chips that are not knobs (AUTO, TONE CURVE) stack over it, wrapped in the column's own .chip-row. */ .col-dev { width: 320px; } -.dev-panels { display: flex; flex-direction: column; gap: 10px; } -.dev-panel { border-top: 1px solid var(--border-soft); padding-top: 8px; } -.dev-panel-head { - display: flex; justify-content: space-between; align-items: center; - width: 100%; padding: 0; border: 0; background: transparent; - font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--text); cursor: pointer; +.dev-panels { + display: flex; + flex-direction: column; + background: #18181b; + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + overflow: hidden; +} +.dev-panel { + border-top: 1px solid var(--border-soft); +} +.dev-panel:first-child { + border-top: none; +} +.dev-panel.disabled .dev-panel-body { + opacity: 0.45; + pointer-events: none; +} +.dev-panel-head { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 8px 12px; + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; + user-select: none; + transition: background 0.15s ease; +} +.dev-panel-head:hover { + background: rgba(255, 255, 255, 0.03); +} +.dev-panel-head-left { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: #f4f4f5; + letter-spacing: -0.01em; +} +.dev-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + font-size: 11px; + color: #a1a1aa; +} +.dev-panel-label { + font-size: 12.5px; + font-weight: 600; + color: #f4f4f5; + text-transform: none; + letter-spacing: normal; +} +.dev-toggle-switch { + position: relative; + width: 28px; + height: 16px; + border-radius: 999px; + background: #3f3f46; + border: none; + padding: 0; + cursor: pointer; + transition: background-color 0.2s ease; + flex: 0 0 28px; +} +.dev-toggle-switch.on { + background: #3b82f6; +} +.dev-toggle-knob { + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + transition: transform 0.2s ease; +} +.dev-toggle-switch.on .dev-toggle-knob { + transform: translateX(12px); +} +.dev-panel-body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 14px 12px; +} +.dev-lut-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + padding: 2px 0; +} +.dev-lut-label { + font-size: 11.5px; + color: #a1a1aa; + font-weight: 500; +} +.dev-lut-select { + width: 150px; + padding: 4px 8px; + font-size: 11.5px; + background: #1f1f23; + border: 1px solid #3f3f46; + border-radius: 4px; + color: #f4f4f5; + cursor: pointer; + outline: none; + transition: border-color 0.15s ease; +} +.dev-lut-select:hover { + border-color: #52525b; +} +.dev-lut-select:focus { + border-color: #3b82f6; +} +.dev-subgroup { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 4px; +} +.dev-subgroup-title { + font-size: 11px; + font-weight: 600; + color: #a1a1aa; + margin: 6px 0 2px 0; + letter-spacing: 0.02em; +} +.dev-panels .mini-slider { + min-width: 0; +} +.dev-panels .mini-head { + display: flex; + align-items: center; + gap: 10px; +} +.dev-panels .mini-head span { + flex: 0 0 88px; + width: 88px; + font-size: 11.5px; + font-weight: 400; + color: #d4d4d8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-transform: none; + letter-spacing: normal; +} +.dev-panels .mini-head input[type="range"] { + flex: 1 1 auto; + min-width: 0; + height: 16px; +} +.dev-panels .mini-head b { + flex: 0 0 46px; + width: 46px; + text-align: right; + font-family: var(--mono); + font-size: 11.5px; + font-weight: 500; + color: #38bdf8; + letter-spacing: 0; + font-variant-numeric: tabular-nums; +} +.dev-panels input[type="range"]::-webkit-slider-runnable-track { + background: #38383e; + height: 2px; + border-radius: 999px; +} +.dev-panels input[type="range"]::-moz-range-track { + background: #38383e; + height: 2px; + border-radius: 999px; +} +.dev-panels input[type="range"].track-temp::-webkit-slider-runnable-track { + background: linear-gradient(90deg, #3b82f6 0%, #94a3b8 50%, #f97316 100%); + height: 3px; + border-radius: 999px; +} +.dev-panels input[type="range"].track-tint::-webkit-slider-runnable-track { + background: linear-gradient(90deg, #22c55e 0%, #94a3b8 50%, #d946ef 100%); + height: 3px; + border-radius: 999px; +} +.dev-panels input[type="range"].track-temp::-moz-range-track { + background: linear-gradient(90deg, #3b82f6 0%, #94a3b8 50%, #f97316 100%); + height: 3px; + border-radius: 999px; +} +.dev-panels input[type="range"].track-tint::-moz-range-track { + background: linear-gradient(90deg, #22c55e 0%, #94a3b8 50%, #d946ef 100%); + height: 3px; + border-radius: 999px; +} +.dev-panels input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + margin-top: -5px; + border: 1.5px solid #d4d4d8; + border-radius: 50%; + background: #18181b; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + cursor: pointer; + transition: border-color 0.15s ease, transform 0.15s ease; +} +.dev-panels input[type="range"]::-moz-range-thumb { + -webkit-appearance: none; + appearance: none; + box-sizing: border-box; + width: 12px; + height: 12px; + border: 1.5px solid #d4d4d8; + border-radius: 50%; + background: #18181b; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + cursor: pointer; + transition: border-color 0.15s ease, transform 0.15s ease; +} +.dev-panels input[type="range"]:hover::-webkit-slider-thumb, +.dev-panels input[type="range"]:focus-visible::-webkit-slider-thumb { + border-color: #60a5fa; + transform: scale(1.15); +} +.dev-panels input[type="range"]:hover::-moz-range-thumb, +.dev-panels input[type="range"]:focus-visible::-moz-range-thumb { + border-color: #60a5fa; + transform: scale(1.15); } -.dev-panel-head:hover { color: var(--accent); } -/* The caret is the panel's whole affordance — the head is a line of uppercase - and says nothing about being a control — so it is drawn larger than the label - it sits beside, and takes the accent while the pointer is on the head. */ -.dev-caret { font-size: 17px; line-height: 1; opacity: 0.75; } -.dev-panel-head:hover .dev-caret { opacity: 1; } -.dev-panel-body { display: flex; flex-direction: column; gap: 10px; padding-top: 8px; } -/* A pick that is a set of its own rather than a row of the panel's: framed and - named, so the four stops of D.RANGE read as one setting and not as four more - rows of DETAIL & EFFECTS. No colour rules: the frame is the token's. */ .dev-box { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 8px; } .dev-box-title { display: block; margin-bottom: 6px; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-dim); } -/* A row that cannot be edited at all: a PRO knob on the LITE build. It reads as - a row, not as a button — the tag is what says the row is not a slider. */ .dev-row { display: flex; flex-direction: column; gap: 4px; } .dev-row.locked { flex-direction: row; align-items: center; gap: 6px; @@ -1880,24 +2098,6 @@ form[data-key='immich-modal'] { text-align: left; opacity: 0.5; cursor: not-allowed; } .dev-pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); } -/* WB's two knobs: the track is the hue the value moves towards, the way - Lightroom's temperature and tint sliders are painted — the same line as - everywhere else, only coloured, and a shade thicker so the cast it names can - be read. The node is the app's own ring and is left to the rule above: the - line says where the value is, the ring says where the knob is. The colour sits - on the track and not on the input (where it used to be drawn) because the - line is what is read — a background on the box would have shown through - around the hairline every other knob is drawn with. - Each selector keeps the base rule's own `input[type="range"]` on it: the - class alone ties with that rule on specificity, and the later one — the plain - grey — would have won the track. */ -input[type="range"].track-temp::-webkit-slider-runnable-track { background: linear-gradient(90deg, #3f6fd8, #b9c2cc 50%, #e8a33d); height: 4px; } -input[type="range"].track-tint::-webkit-slider-runnable-track { background: linear-gradient(90deg, #3f9d55, #b9c2cc 50%, #c04ec4); height: 4px; } -input[type="range"].track-temp::-moz-range-track { background: linear-gradient(90deg, #3f6fd8, #b9c2cc 50%, #e8a33d); height: 4px; } -input[type="range"].track-tint::-moz-range-track { background: linear-gradient(90deg, #3f9d55, #b9c2cc 50%, #c04ec4); height: 4px; } -/* The node is centred on a track twice the hairline's height, so it shifts up - the one pixel the base rule does not account for. */ -input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track-tint::-webkit-slider-thumb { margin-top: -3px; } /* --- responsive --------------------------------------------------------- */ @media (max-width: 860px) { diff --git a/docker/frontend/src/ui/DevelopPanels.tsx b/docker/frontend/src/ui/DevelopPanels.tsx index f20421d..6dda390 100644 --- a/docker/frontend/src/ui/DevelopPanels.tsx +++ b/docker/frontend/src/ui/DevelopPanels.tsx @@ -17,39 +17,109 @@ const DEF_BY_KEY = new Map( // here with them; anything else is the bare slider. const TRACK: Record = { temperature: 'temp', tint: 'tint' }; +const LABEL_MAP: Record = { + temperature: 'Temperature', + tint: 'Tint', + color: 'Saturation', + vibrance: 'Vibrance', + exposure: 'Exposure', + ev: 'EV', + contrast: 'Contrast', + highlight: 'Highlights', + shadow: 'Shadows', + whites: 'Whites', + blacks: 'Blacks', + clarity: 'Clarity', + dehaze: 'Dehaze', + denoise: 'Noise Reduction', + sharpening: 'Sharpening', + vignette: 'Amount', + hdf: 'Highlight Diffusion', + grain: 'Grain', + grainSize: 'Grain Size', +}; + const PANELS: { slug: string; title: string; - rows: string[]; - // A slot rides inside its panel, where the parent's own select belongs in the - // reading order — before the panel's knobs except in DETAIL & EFFECTS, whose - // D.RANGE selects close the column. + groups?: { + title?: string; + rows: string[]; + }[]; + rows?: string[]; slot?: 'wb' | 'effects'; - // A pick that closes its panel rather than opening it: drawn under the rows, - // where WB's two Color Chromes belong (they are read after the pair of tracks - // they are the other axis of) and where TONE's curve belongs, standing under - // the range it is the shape of. foot?: 'wbFoot' | 'toneFoot'; slotFirst?: boolean; }[] = [ - { slug: 'wb', title: 'WB', rows: ['temperature', 'tint'], slot: 'wb', slotFirst: true, foot: 'wbFoot' }, - // EV leads TONE: it is the one number the whole range hangs off, and the - // exposure below it is the finer move on the same axis. { - slug: 'tone', - title: 'TONE', - rows: ['ev', 'exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'], + slug: 'basic', + title: 'Basic Correction', + groups: [ + { + title: 'Color', + rows: ['temperature', 'tint', 'color'], + }, + { + title: 'Light', + rows: ['exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'], + }, + ], + }, + { + slug: 'creative', + title: 'Creative', + rows: ['clarity', 'dehaze', 'vibrance', 'grain', 'grainSize'], + }, + { + slug: 'curves', + title: 'Curves', foot: 'toneFoot', }, - { slug: 'presence', title: 'PRESENCE', rows: ['clarity', 'dehaze', 'vibrance', 'color'] }, { - slug: 'effects', - title: 'DETAIL & EFFECTS', - rows: ['denoise', 'sharpening', 'vignette', 'hdf', 'grain', 'grainSize'], + slug: 'wheels', + title: 'Color Wheels & Match', + slot: 'wb', + foot: 'wbFoot', + }, + { + slug: 'hsl', + title: 'HSL Secondary', slot: 'effects', + rows: ['denoise', 'sharpening'], + }, + { + slug: 'vignette', + title: 'Vignette', + rows: ['vignette', 'hdf'], }, ]; +function ToggleSwitch({ + checked, + onChange, + ariaLabel, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + ariaLabel: string; +}) { + return ( + + ); +} + function DevRow({ paramKey, adj, @@ -71,21 +141,33 @@ function DevRow({ if (def.pro && !pro) { return ( ); } + + // Format value to 1 decimal place matching Lumetri Color in the screenshot + const formatVal = (v: number): string => { + if (paramKey === 'temperature') { + return ((v - 5500) / 100).toFixed(1); + } + if (paramKey === 'color') { + return (100 + v).toFixed(1); + } + return v.toFixed(1); + }; + return ( set(def.set(v))} @@ -94,35 +176,46 @@ function DevRow({ ); } -// Which panels the studio draws open, under this key. The list is the panels' -// slugs, comma-joined; a slug no panel answers to is dropped on the way in, so a -// panel that is renamed or taken out does not leave a hole in the column. +// Which panels the studio draws open, under this key. const OPEN_KEY = 'recipescam.develop.open'; const SOLO_KEY = 'recipescam.develop.soloMode'; -// The studio's right column: every panel's knobs behind a disclosure that folds -// its own rows away, and the column opens with all of them folded — a dozen rows -// of sliders is a wall to scroll past, and the knob being looked for is named by -// its panel's head. What the reader opens is remembered, so the second visit -// opens the way the first was left. One undo step is the parent's `set`. export function DevelopPanels({ adj, set, pro = false, onPro, slots, + baseFilter = 'none', + onBaseFilterChange, }: { adj: ColorAdjustments; set: (patch: Partial) => void; pro?: boolean; onPro?: () => void; slots?: { wb?: ReactNode; wbFoot?: ReactNode; toneFoot?: ReactNode; effects?: ReactNode }; + baseFilter?: string; + onBaseFilterChange?: (filter: string) => void; }) { const [soloMode, setSoloMode] = useState(() => recall(SOLO_KEY) === 'true'); const [open, setOpen] = useState>(() => { - const saved = new Set((recall(OPEN_KEY) ?? '').split(',')); - return Object.fromEntries(PANELS.filter((panel) => saved.has(panel.slug)).map((panel) => [panel.slug, true])); + const raw = recall(OPEN_KEY); + const saved = new Set((raw ?? 'basic').split(',')); + if (!raw || saved.size === 0 || (saved.size === 1 && saved.has(''))) { + saved.add('basic'); + } + return Object.fromEntries(PANELS.map((panel) => [panel.slug, saved.has(panel.slug)])); }); + + const [enabled, setEnabled] = useState>({ + basic: true, + creative: true, + curves: true, + wheels: true, + hsl: false, + vignette: true, + }); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); useEffect(() => { @@ -158,7 +251,7 @@ export function DevelopPanels({ setSoloMode(nextSolo); keep(SOLO_KEY, String(nextSolo)); if (nextSolo) { - const activeSlug = PANELS.find((p) => open[p.slug])?.slug ?? 'wb'; + const activeSlug = PANELS.find((p) => open[p.slug])?.slug ?? 'basic'; const next = Object.fromEntries(PANELS.map((p) => [p.slug, p.slug === activeSlug])); saveOpen(next); } @@ -183,10 +276,15 @@ export function DevelopPanels({
{PANELS.map((panel) => { const isOpen = open[panel.slug]; + const isEnabled = enabled[panel.slug] ?? true; const slot = panel.slot ? slots?.[panel.slot] : null; const foot = panel.foot ? slots?.[panel.foot] : null; return ( -
+
{isOpen ? (
+ {panel.slug === 'basic' && ( +
+ Input LUT + +
+ )} + {panel.groups + ? panel.groups.map((grp, gi) => ( +
+ {grp.title ?
{grp.title}
: null} + {grp.rows.map((key) => ( + + ))} +
+ )) + : null} {panel.slotFirst ? slot : null} - {panel.rows.map((key) => ( - - ))} + {panel.rows + ? panel.rows.map((key) => ( + + )) + : null} {panel.slotFirst ? null : slot} {foot}