feat(studio): redesign LIGHT tab to Lumetri style and fix mobile inpaint freeze with worker proxy and patch cache

This commit is contained in:
2026-10-14 11:01:52 +07:00
parent e9cc2bb15a
commit d52d4c4281
5 changed files with 560 additions and 135 deletions
+8
View File
@@ -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) {
+8 -1
View File
@@ -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 }));
}}
/>
</>
) : (
+127 -59
View File
@@ -50,11 +50,20 @@ interface Loaded {
let loaded: Promise<Loaded> | 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> {
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<Uint8Array | null> | 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<void> {
patchCache = null;
const pending = loaded;
loaded = null;
if (!pending) return;
@@ -199,7 +222,7 @@ export async function inpaintCrop(
height: number,
hole: Uint8Array
): Promise<Uint8Array | null> {
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<Uint8Array | null> => {
// 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),
+237 -37
View File
@@ -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) {
+180 -38
View File
@@ -17,39 +17,109 @@ const DEF_BY_KEY = new Map(
// here with them; anything else is the bare slider.
const TRACK: Record<string, 'temp' | 'tint' | undefined> = { temperature: 'temp', tint: 'tint' };
const LABEL_MAP: Record<string, string> = {
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 (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
className={`dev-toggle-switch${checked ? ' on' : ''}`}
onClick={(e) => {
e.stopPropagation();
onChange(!checked);
}}
>
<span className="dev-toggle-knob" />
</button>
);
}
function DevRow({
paramKey,
adj,
@@ -71,21 +141,33 @@ function DevRow({
if (def.pro && !pro) {
return (
<button type="button" className="dev-row locked" data-key={`dev-${paramKey}`} onClick={() => onPro?.()}>
{def.label}
{LABEL_MAP[paramKey] ?? def.label}
<span className="dev-pro">PRO</span>
</button>
);
}
// 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 (
<MiniSlider
label={def.label}
label={LABEL_MAP[paramKey] ?? def.label}
value={def.get(adj)}
min={def.min}
max={def.max}
step={def.step}
defaultValue={def.defaultValue}
track={TRACK[paramKey]}
format={def.display}
format={formatVal}
prefix="dev"
dataKey={`dev-${paramKey}`}
onChange={(v) => 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<ColorAdjustments>) => void;
pro?: boolean;
onPro?: () => void;
slots?: { wb?: ReactNode; wbFoot?: ReactNode; toneFoot?: ReactNode; effects?: ReactNode };
baseFilter?: string;
onBaseFilterChange?: (filter: string) => void;
}) {
const [soloMode, setSoloMode] = useState<boolean>(() => recall(SOLO_KEY) === 'true');
const [open, setOpen] = useState<Record<string, boolean>>(() => {
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<Record<string, boolean>>({
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({
<div className="dev-panels">
{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 (
<section key={panel.slug} className="dev-panel" data-key={`dev-panel-${panel.slug}`}>
<section
key={panel.slug}
className={`dev-panel${isEnabled ? '' : ' disabled'}`}
data-key={`dev-panel-${panel.slug}`}
>
<button
type="button"
className="dev-panel-head"
@@ -197,15 +295,59 @@ export function DevelopPanels({
setContextMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 150) });
}}
>
<span className="dev-panel-label">{panel.title}</span>
<span className="dev-caret" aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
<div className="dev-panel-head-left">
<span className="dev-chevron" aria-hidden="true">
{isOpen ? '⌵' : '›'}
</span>
<span className="dev-panel-label">{panel.title}</span>
</div>
<ToggleSwitch
checked={isEnabled}
ariaLabel={`Bật/tắt ${panel.title}`}
onChange={(chk) => setEnabled((prev) => ({ ...prev, [panel.slug]: chk }))}
/>
</button>
{isOpen ? (
<div className="dev-panel-body">
{panel.slug === 'basic' && (
<div className="dev-lut-row">
<span className="dev-lut-label">Input LUT</span>
<select
className="dev-lut-select"
value={baseFilter}
onChange={(e) => onBaseFilterChange?.(e.target.value)}
>
<option value="none">None</option>
<option value="provia">Fuji Provia</option>
<option value="velvia">Fuji Velvia</option>
<option value="astia">Fuji Astia</option>
<option value="classic-chrome">Classic Chrome</option>
<option value="classic-neg">Classic Neg.</option>
<option value="eterna">Eterna Cinema</option>
<option value="bleach-bypass">Bleach Bypass</option>
<option value="nostalgic-neg">Nostalgic Neg.</option>
<option value="reala-ace">Reala Ace</option>
<option value="monochrome">Monochrome</option>
<option value="acros">Acros</option>
</select>
</div>
)}
{panel.groups
? panel.groups.map((grp, gi) => (
<div key={gi} className="dev-subgroup">
{grp.title ? <div className="dev-subgroup-title">{grp.title}</div> : null}
{grp.rows.map((key) => (
<DevRow key={key} paramKey={key} adj={adj} set={set} pro={pro} onPro={onPro} />
))}
</div>
))
: null}
{panel.slotFirst ? slot : null}
{panel.rows.map((key) => (
<DevRow key={key} paramKey={key} adj={adj} set={set} pro={pro} onPro={onPro} />
))}
{panel.rows
? panel.rows.map((key) => (
<DevRow key={key} paramKey={key} adj={adj} set={set} pro={pro} onPro={onPro} />
))
: null}
{panel.slotFirst ? null : slot}
{foot}
</div>