feat(studio): redesign LIGHT tab to Lumetri style and fix mobile inpaint freeze with worker proxy and patch cache
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 }));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user