diff --git a/docker/frontend/package-lock.json b/docker/frontend/package-lock.json index a741938..04d0642 100644 --- a/docker/frontend/package-lock.json +++ b/docker/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "canvaskit-wasm": "^0.42.0", "exifr": "^7.1.3", + "libraw-wasm": "^1.6.0", "onnxruntime-web": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -1605,6 +1606,12 @@ "node": ">=6" } }, + "node_modules/libraw-wasm": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/libraw-wasm/-/libraw-wasm-1.6.0.tgz", + "integrity": "sha512-VfvWq2XGGkc07wexymRLn1H82j6smmC1LAbVl41vnW2Z5RrEPLN+YHi1Hmga2TbxxrY4rOjmTwHwSyTZmypE1g==", + "license": "ISC" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", diff --git a/docker/frontend/package.json b/docker/frontend/package.json index fe2a9b7..4ad2225 100644 --- a/docker/frontend/package.json +++ b/docker/frontend/package.json @@ -15,6 +15,7 @@ "dependencies": { "canvaskit-wasm": "^0.42.0", "exifr": "^7.1.3", + "libraw-wasm": "^1.6.0", "onnxruntime-web": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 8656128..720122e 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -153,6 +153,18 @@ export interface GradientMask { exposure: number; contrast: number; saturation: number; + // The spec's section 4 knobs, the frame-wide ones the mask can now restrict to + // its own area: HIGHLIGHT and SHADOW through its smoothstep soft masks, WHITE + // and BLACK at the two ends of the histogram, and the two spatial moves — + // CLARITY against the frame's own blurred reference and DEHAZE through the dark + // channel. Same -10..+10 as the app's own rulers; absent on a mask stored + // before they existed, and then the shader reads 0 for it. + highlights?: number; + shadows?: number; + whites?: number; + blacks?: number; + clarity?: number; + dehaze?: number; } export interface ColorAdjustments { @@ -169,6 +181,10 @@ export interface ColorAdjustments { blacks?: number; // -10 to +10 (WB black point: per channel, the toe) denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back) clarity: number; // -10 to +10 (mapped to matrix convolution / bloom) + // FX tab > DEHAZE (raw_parameter_processing_gradient_mask_algorithms.md §3.2): + // 0 to 10, the Dark Channel Prior pass — the scattered light the haze puts in + // front of the subject, taken back out. Absent on an older recipe = 0. + dehaze?: number; grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale) grainSize?: number; // 50 to 200 (percent of the stock's own grain cell; 100 = the stock's own) colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect diff --git a/docker/frontend/shared/utils/gradientMask.ts b/docker/frontend/shared/utils/gradientMask.ts index 4804cc1..34a09ac 100644 --- a/docker/frontend/shared/utils/gradientMask.ts +++ b/docker/frontend/shared/utils/gradientMask.ts @@ -1,4 +1,5 @@ import type { GradientMask } from '../types'; +import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA } from './toneShader'; // FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local // adjustments that are a SHAPE rather than a whole-frame knob. @@ -21,11 +22,14 @@ import type { GradientMask } from '../types'; // skiaShim.ts), stage for stage: the one pass the spec asks for per mask, in // the order the user drew them, each reading what the one before it left. // -// ponytail: the spec's next rung — Highlights/Shadows isolated with pow(luma, 3) -// weight masks — is not here. Its own section calls it an upgrade, and the three -// knobs are what "gradient mask" means until a photo shows a sky that needs -// rescuing apart from the grass under it. The same rung holds Lightroom's -// per-mask invert and colour/tone ranges. +// The spec's section 4 — "the system needs to restrict all the above effects to +// operate only within the mask's area" — is the rest of the block below: the +// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and +// BLACK move, and the two spatial ones (CLARITY against the frame's own blur, +// DEHAZE through the dark channel) that the caller hands in the blurred +// reference and the atmospheric light for. Every one of them rides the same +// alpha the shape produces and lands through the same `mix`, so a mask at half +// strength is half of the move. export const MASK_KIND = { linear: 0, radial: 1 } as const; // Exposure is stored as the EV itself — the spec's own -5..+5 — because that is // what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units @@ -54,7 +58,20 @@ const clampEV = (v: number) => (v < -MASK_EXPOSURE_MAX ? -MASK_EXPOSURE_MAX : v // cannot produce a shape the overlay and the renderer disagree about. A mask // with no shape (a linear drag of no length, an ellipse of no radius) is // dropped: it would paint nothing and could never be taken hold of on the photo. -export function readMasks(masks: GradientMask[] | undefined): GradientMask[] { +// What readMasks hands back: the stored mask with every knob resolved to a +// number. The two spatial ones and the four tonal ones are optional in the +// stored type (a mask saved before they existed has none), so the reader's +// return type is the one that says they are there. +export type ReadMask = GradientMask & { + highlights: number; + shadows: number; + whites: number; + blacks: number; + clarity: number; + dehaze: number; +}; + +export function readMasks(masks: GradientMask[] | undefined): ReadMask[] { if (!Array.isArray(masks)) return []; return masks .filter((m) => m?.kind === 'linear' || m?.kind === 'radial') @@ -71,20 +88,44 @@ export function readMasks(masks: GradientMask[] | undefined): GradientMask[] { exposure: clampEV(num(m.exposure, 0)), contrast: clampA(num(m.contrast, 0)), saturation: clampA(num(m.saturation, 0)), + // The spec's section 4 knobs: the two tone soft masks, the two ends, and + // the two spatial moves. All -10..+10 like every other knob here, all 0 on + // a mask stored before they existed. + highlights: clampA(num(m.highlights, 0)), + shadows: clampA(num(m.shadows, 0)), + whites: clampA(num(m.whites, 0)), + blacks: clampA(num(m.blacks, 0)), + clarity: clampA(num(m.clarity, 0)), + dehaze: clampA(num(m.dehaze, 0)), })) .filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0)); } +// The two spatial knobs need the frame's own blurred reference (and DEHAZE the +// atmospheric light) handed to the shader as a second child; the four tonal ones +// need nothing. One question, asked in one place, so the renderer builds the +// blur for exactly the masks that read it and the effect is cached for the same +// answer. +export function masksHaveSpatial(masks: GradientMask[]): boolean { + return readMasks(masks).some((m) => m.clarity !== 0 || m.dehaze !== 0); +} + // The uniform block the shader for `n` masks reads: the shapes, the ellipse -// parameters, the knobs with the kind, then the frame the fractions are of. -// Declaration order, arrays expanded — one buffer is one upload per render, the -// same shape healUniforms and mosaicUniforms use. Its length is a function of -// the list, not a fixed capacity, because the shader carries exactly the masks -// the recipe holds. -export function maskUniforms(masks: GradientMask[], width: number, height: number): Float32Array { +// parameters, the knobs with the kind, the tone soft masks, the spatial pair, +// then the frame the fractions are of — and, when the caller has one, the +// atmospheric light the dehaze reads. Declaration order, arrays expanded — one +// buffer is one upload per render, the same shape healUniforms and +// mosaicUniforms use. Its length is a function of the list, not a fixed +// capacity, because the shader carries exactly the masks the recipe holds. +export function maskUniforms( + masks: GradientMask[], + width: number, + height: number, + air?: [number, number, number] | null +): Float32Array { const list = readMasks(masks); const n = list.length; - const u = new Float32Array((3 * n + 1) * 4); + const u = new Float32Array((5 * n + 1 + (air ? 1 : 0)) * 4); for (let i = 0; i < n; i++) { const m = list[i]; u.set([m.x, m.y, m.ex, m.ey], i * 4); @@ -95,24 +136,61 @@ export function maskUniforms(masks: GradientMask[], width: number, height: numbe [m.exposure, m.contrast / 10, m.saturation / 10, MASK_KIND[m.kind]], (2 * n + i) * 4 ); + // The tone soft masks and the two ends, then the spatial pair. + u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4); + u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4); } - u.set([width, height, 0, 0], 3 * n * 4); + u.set([width, height, 0, 0], 5 * n * 4); + if (air) u.set([air[0], air[1], air[2], 0], (5 * n + 1) * 4); return u; } // The colour inside a mask, in the spec's own order and its own formulas: // exposure first (a power of two, so a stop is a stop), then contrast about the -// middle, then saturation as a mix away from the pixel's own REC-709 luma. The -// result is clamped to the range a file can hold — the spec's own guard, and it -// is `mix`ed back over the base by the mask's alpha, so a mask at half strength -// is half of the move rather than the whole of it. -const adjustFn = ` -half3 maskAdjust(half3 c, float3 a) { +// middle, then saturation as a mix away from the pixel's own REC-709 luma. Then +// the spec's section 2 and 3 on top — HIGHLIGHT and SHADOW through the two +// smoothstep soft masks its own formula names (`M_shadow = 1 - smoothstep(0, +// 0.5, L)`, `M_highlight = smoothstep(0.5, 1, L)`), WHITE and BLACK as a gain on +// the end each one owns, and the two spatial ones against the blurred reference +// the caller hands in. The result is clamped to the range a file can hold — the +// spec's own guard, and it is `mix`ed back over the base by the mask's alpha, so +// a mask at half strength is half of the move rather than the whole of it. +// +// `blur` is the frame's bilateral reference (the same one CLARITY and DEHAZE use +// frame-wide) and `air` the atmospheric light; both are the constants 0 when the +// shader was built without the second child, and then the two spatial knobs are +// left out of the pass — the caller only builds that child for masks that ask +// for them (masksHaveSpatial). +const adjustFn = (spatial: boolean) => ` +half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx${spatial ? ', half3 blur, float3 air' : ''}) { c = c * half(pow(2.0, a.x)); c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5); - half l = dot(c, half3(0.2126, 0.7152, 0.0722)); - c = mix(half3(l), c, half(1.0 + a.z)); - return clamp(c, half3(0.0), half3(1.0)); + half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722)); + // The spec's soft masks, computed in float and narrowed: smoothstep on half + // is one more type the shader does not have to guess at. + float lf = clamp(float(l), 0.0, 1.0); + float ms = 1.0 - smoothstep(0.0, 0.5, lf); + float mh = smoothstep(0.5, 1.0, lf); + // Half a stop of tone at the knob's own ceiling, the weight the frame-wide + // HIGHLIGHT/SHADOW pass uses; + lifts, - pulls back, and the two masks cannot + // both be 1 at the same pixel. + c = clamp(c + half3(half(0.5 * (tone.y * ms - tone.x * mh))), half3(0.0), half3(1.0)); + c = c * half(1.0 + 0.5 * tone.z * mh); + c = c * half(1.0 + 0.5 * tone.w * ms); + half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722)); + c = mix(half3(nl), c, half(1.0 + a.z)); +${spatial ? ` if (fx.x != 0.0) { + // CLARITY (doc section 3.1): the pixel against its own blurred surroundings. + c = clamp(c + half3(half(fx.x * ${CLARITY_GAIN.toFixed(1)})) * (c - blur), half3(0.0), half3(1.0)); + } + if (fx.y != 0.0) { + // DEHAZE (doc section 3.2): the dark channel of the patch is the haze. + half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05))); + half dark = min(min(blur.r / aa.r, blur.g / aa.g), blur.b / aa.b); + half t = clamp(half(1.0 - fx.y * ${DEHAZE_MAX_OMEGA} * clamp(dark, half(0.0), half(1.0))), half(${DEHAZE_FLOOR_T}), half(1.0)); + c = clamp((c - aa) / t + aa, half3(0.0), half3(1.0)); + } +` : ''} return clamp(c, half3(0.0), half3(1.0)); } `; @@ -123,7 +201,7 @@ half3 maskAdjust(half3 c, float3 a) { // product, and its zero-length guard is the filter in readMasks rather than a // branch here: a mask with no length is not a mask. A radial mask's alpha is // 1 inside the feather and 0 at the rim. -const maskBlock = (i: number) => ` +const maskBlock = (i: number, spatial: boolean) => ` { float a = 0.0; if (adj[${i}].w < 0.5) { @@ -140,7 +218,7 @@ const maskBlock = (i: number) => ` a = 1.0 - smoothstep(max(0.0, 1.0 - rads[${i}].w), 1.0, d); } if (a > 0.0) { - c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}].xyz), half(a)); + c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}]${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a)); } } `; @@ -152,17 +230,22 @@ const maskBlock = (i: number) => ` // a repair laid inside a mask then borrows pixels that already carry the mask's // light, which is what makes it match its surroundings, and a MOSAIC over a mask // hides what the mask left. The frame the masks are of comes in as `size`, the -// same uniform HEAL and MOSAIC take. -export function gradientMaskSkSL(count: number): string { +// same uniform HEAL and MOSAIC take. `spatial` adds the two things only CLARITY +// and DEHAZE read: the frame's own blurred reference as a second child, whose +// image is in the same coordinates the pass runs in, and the atmospheric light. +export function gradientMaskSkSL(count: number, spatial = false): string { return ` uniform shader img; uniform float4 rects[${count}]; uniform float4 rads[${count}]; uniform float4 adj[${count}]; +uniform float4 tone[${count}]; +uniform float4 fx[${count}]; uniform float4 size; -${adjustFn} +${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''} +${adjustFn(spatial)} half4 main(float2 pos) { - half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i)).join('')} + half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')} return c; } `; diff --git a/docker/frontend/shared/utils/paramDefs.ts b/docker/frontend/shared/utils/paramDefs.ts index 8256dc7..6ad04a1 100644 --- a/docker/frontend/shared/utils/paramDefs.ts +++ b/docker/frontend/shared/utils/paramDefs.ts @@ -179,6 +179,21 @@ export const PARAM_DEFS: { get: (a) => a.clarity, set: (v) => ({ clarity: v }), }, + { + // The haze the frame's own pixels carry (dark channel prior, toneShader's + // DEHAZE_SKSL): no negative side, because there is no way to put scattered + // light back that is honest about where it went. Its chip sits with the + // other spatial knob it shares a blurred reference with. + key: 'dehaze', + label: 'DEHAZE', + min: 0, + max: 10, + step: 1, + defaultValue: 0, + display: String, + get: (a) => a.dehaze ?? 0, + set: (v) => ({ dehaze: v }), + }, { key: 'sharpening', label: 'SHARPENING', diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index ac99c56..bf808a3 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -330,6 +330,95 @@ export function clarityUniforms(a: number, pxX: number, pxY: number) { return { a, px: [pxX, pxY] }; } +// CLARITY's reference image B, one axis at a time — the multiple-pass +// architecture of ki_n_tr_c_multiple_passes_cho_webgpu.md: a single 15x15 kernel +// reads 225 pixels per pixel, a separable pair (1x15 then 15x1) reads 30. The +// kernel is the doc's bilateral filter: the gaussian weight falls off along the +// axis, and a range weight kills a tap whose colour is nothing like the centre's, +// so an edge is not blurred across and the reference does not ghost it. +// `dir` is one tap's step in the caller's units (px along ONE axis, the other +// component 0), so a preview and the file blur the same fraction of the frame. +// SkSL has no dynamic loop bound here, so the 15 taps are the doc's own count. +export const CLARITY_BLUR_SKSL = ` +uniform shader src; +uniform float2 dir; +vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + vec3 sum = c.rgb; + float total = 1.0; + for (int i = 1; i <= 15; i++) { + float fi = float(i); + float g = exp(-0.5 * (fi / 5.0) * (fi / 5.0)); + vec4 a1 = src.eval(xy + dir * fi); + vec4 a2 = src.eval(xy - dir * fi); + vec3 d1 = a1.rgb - c.rgb; + vec3 d2 = a2.rgb - c.rgb; + float r1 = exp(-dot(d1, d1) * 24.0); + float r2 = exp(-dot(d2, d2) * 24.0); + sum += g * (r1 * a1.rgb + r2 * a2.rgb); + total += g * (r1 + r2); + } + return vec4(sum / total, c.a); +} +`; + +// CLARITY (positive), pass 3 of the doc's architecture: the frame against its +// own blurred reference. `orig + (orig - B) * strength` — the local contrast the +// doc asks for, clamped because a file cannot hold more than white. Runs on the +// ENCODED pixels like every other grade here (only EXPOSURE_SKSL is linear +// light, see colorUtils.exposureStops) — the doc's formula is written for linear +// light, and moving the whole renderer there is a bigger change than this pass. +export const CLARITY_BLEND_SKSL = ` +uniform shader original; +uniform shader blurred; +uniform float strength; +vec4 main(vec2 xy) { + vec4 c = original.eval(xy); + vec3 b = blurred.eval(xy).rgb; + return vec4(clamp(c.rgb + (c.rgb - b) * strength, 0.0, 1.0), c.a); +} +`; + +// Strength that keeps CLARITY 10 where the 3x3 kernel had it: that kernel was +// `c*(1+4a) - a*sum` with a = 0.8, i.e. `c + 3.2*(c - mean4)`, so the same 3.2 +// lands the same local contrast through the wider bilateral reference. +export const CLARITY_GAIN = 3.2; + +// DEHAZE — raw_parameter_processing_gradient_mask_algorithms.md, section 3.2. +// Haze is scattered light: it lifts the DARKEST channel of every patch, which is +// the Dark Channel Prior. `haze` is the frame's own bilateral reference (the +// patch average the blur already computes), `air` the atmospheric light the +// caller estimated from the frame, and the transmission is what is left of the +// dark channel once the haze is taken out of it, floored so a flat sky cannot +// divide by zero. Then `J = (I - A)/t + A` removes the scattered light and the +// division is also the contrast stretch the doc asks for afterwards. +export const DEHAZE_SKSL = ` +uniform shader img; +uniform shader haze; +uniform float3 air; +uniform float amount; +uniform float floorT; +vec4 main(vec2 xy) { + vec3 c = clamp(img.eval(xy).rgb, 0.0, 1.0); + vec3 b = clamp(haze.eval(xy).rgb, 0.0, 1.0); + vec3 a = max(air, vec3(0.05)); + float dark = min(min(b.r / a.r, b.g / a.g), b.b / a.b); + float t = clamp(1.0 - amount * clamp(dark, 0.0, 1.0), floorT, 1.0); + return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0); +} +`; + +// Ray marching the doc's A estimate would need the histogram; the caller reads a +// 32x32 copy of the frame instead and takes its brightest dark-channel pixel — +// the same 0.1% answer, in one readback (see exportEngine's atmosphericLight). +export const DEHAZE_FLOOR_T = 0.1; +export const DEHAZE_MAX_OMEGA = 0.95; + +export function dehazeUniformArray(air: [number, number, number], amount: number): number[] { + 'worklet'; + return [air[0], air[1], air[2], amount * DEHAZE_MAX_OMEGA, DEHAZE_FLOOR_T]; +} + export interface ToneUniforms { // All zero → no tone adjustment needed (caller can skip the shader pass). dr: number; // 0..1 diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 60d082b..a961d05 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -16,6 +16,8 @@ import { useI18n } from './i18n/I18nProvider'; import { loadEngine, MARK_FACES } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps'; +import { developRaw, isRawName } from './engine/rawDevelop'; +import { clearRaw, readRaw, storeRaw } from './engine/rawStore'; import { cameraSupported } from './engine/camera'; import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session'; import { preloadSuperRes, upscaleJpeg } from './engine/superRes'; @@ -297,6 +299,10 @@ export function Workspace() { ...session.recipe, })); const [simId, setSimId] = useState(() => session.simId ?? null); + // HOVER PREVIEW: the look of the sim or recipe the pointer is over, laid on the + // photo for as long as it stays there. It never becomes `recipe`, so SAVE, + // UNDO and every knob still speak about the look the click left behind. + const [preview, setPreview] = useState(null); const [frameId, setFrameId] = useState(() => session.frameId ?? 'none'); const [crop, setCrop] = useState(() => session.crop ?? 'none'); const [cropRect, setCropRect] = useState(() => session.cropRect ?? DEFAULT_CROP_RECT); @@ -618,16 +624,19 @@ export function Workspace() { .me() .then((r) => alive && setUser(r.user)) .catch(() => undefined); // signed out is a valid state — the demo needs no account - // ...and the photo half of the session, back out of IndexedDB. + // ...and the photo half of the session, back out of IndexedDB. When that is + // empty but a RAW is still parked in OPFS, the RAW is what "the last photo" + // means: develop it again rather than open on nothing. loadPhoto() - .then((photo) => { - if (!alive || !photo) return; - return adoptPhoto( - photo.bytes, - photo.name, - new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), - true - ); + .then(async (photo) => { + if (!alive) return; + if (photo) { + return adoptPhoto(photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true); + } + const parked = await readRaw(); + if (!alive || !parked) return; + const jpeg = await developRaw(parked.bytes); + await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes); }) .catch(() => undefined); return () => { @@ -674,6 +683,14 @@ export function Workspace() { [frameId, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle] ); + // What the stage paints: the hovered look while the pointer is on a sim or a + // recipe chip, the user's own look otherwise. The hovered look brings its + // colour alone — the masks, the dust spots and the mosaic of the photo being + // edited stay on it, or a pointer crossing a chip row would rub them off. + const shownRecipe = preview + ? { ...preview, adjustments: { ...preview.adjustments, masks, heal: healSpots, mosaic: mosaicSpots } } + : recipe; + useEffect(() => { // While the viewfinder is up it grades the camera's frames with this very // recipe, so a preview behind it would only compete for the same renderer — @@ -689,7 +706,7 @@ export function Workspace() { const options = stageOptions; const out = await renderPhoto({ source: previewBytes, - recipe, + recipe: shownRecipe, // An uncommitted crop is framed on the bare photo, so the overlay // measures exactly what the engine will cut. frameId: frame, @@ -733,7 +750,7 @@ export function Workspace() { }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting]); + }, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting]); // The source copy behind previewBytes, re-made only when previewMax moves: // applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back. @@ -845,14 +862,17 @@ export function Workspace() { }, [place, t, capturedAt]); const adoptPhoto = useCallback( - async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => { + async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false, exifBytes?: Uint8Array) => { const preview = await resizedJpeg(bytes, PREVIEW_MAX); - const found = await readGps(bytes); - const foundIso = await readIso(bytes); + // A RAW is decoded to a JPEG with no EXIF of its own, so the tags its + // stamps read (date, position, ISO) come off the camera file instead. + const tagged = exifBytes ?? bytes; + const found = await readGps(tagged); + const foundIso = await readIso(tagged); // The photo's own date, held apart from its GPS: a frame with a date but no // position still stamps the date when the position is typed in or asked of // the device (see the two places the clock is fallen back on, below). - const taken = await readCapturedAt(bytes); + const taken = await readCapturedAt(tagged); setCapturedAt(taken); // The photo's own shape drives the default crop frame, and its own pixels // are what an export is measured against — a 1600px copy would make a @@ -928,10 +948,21 @@ export function Workspace() { setOpenPhotoId(null); try { const bytes = await fileToBytes(file); + if (isRawName(file.name)) { + // A camera RAW: parked in OPFS whole, developed to the JPEG the studio + // works on, and remembered in that same shape by the session — so the + // 30MB is read once, not once per reload. + await storeRaw(bytes, file.name); + const jpeg = await developRaw(bytes); + await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes); + void savePhoto(file.name, jpeg); + return; + } + await clearRaw(); await adoptPhoto(bytes, file.name, file); void savePhoto(file.name, bytes); } catch { - setError(t('err.decode')); + setError(isRawName(file.name) ? t('err.raw') : t('err.decode')); } finally { setBusy(null); } @@ -2268,11 +2299,27 @@ export function Workspace() { }; }; + // The pair a chip carries when its option is a whole look: the photo takes the + // look while the pointer rests on the chip, and gives it back when it leaves. + const lookPreview = (look: Recipe): Pick => ({ + onHover: () => setPreview(look), + onHoverEnd: () => setPreview(null), + }); + + // PHOTO STYLE's own pair, off the sim the chip names. A PRO sim stays out of + // it: a hover that showed its look would hand over what the click gates. + const simPreview = (id: string): Pick => { + const sim = FILM_SIMS.find((s) => s.id === id); + if (!sim || (PRO_SIMS.includes(sim.id) && !pro)) return {}; + return lookPreview(filmSimToRecipe(sim)); + }; + const choiceChips = ( prefix: string, options: { v: string; d: string; pro?: boolean }[], current: string, - onPick: (v: string) => void + onPick: (v: string) => void, + hover?: (v: string) => Partial ): ChipDef[] => options.map((o) => ({ key: `${prefix}:${o.v}`, @@ -2280,6 +2327,7 @@ export function Workspace() { active: current === o.v, pro: o.pro, onClick: () => onPick(o.v), + ...(hover?.(o.v) ?? {}), })); const wmOn = useGeotag || markOn; @@ -2352,6 +2400,31 @@ export function Workspace() { // app's -10..+10, which the shader reads as the spec's -1..+1; and a radial // mask adds the feather it fades over — the percentage of its own axis the // alpha holds full before it dies at the rim. + // The spec's section 4 knobs, one row each: HIGHLIGHT and SHADOW through the + // soft masks the shader builds from the pixel's own luma, WHITE and BLACK as a + // gain on the end each one owns, and the two spatial ones — CLARITY against the + // frame's blurred reference, DEHAZE through the dark channel. All six are the + // same -10..+10 row, so one factory builds them; DEHAZE is the one knob with + // nothing to do on the negative side. + const maskKnobRow = ( + key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze', + label: string + ): SliderDef => { + const v = selMask?.[key] ?? 0; + return { + key: `mask-${key}`, + label, + value: v, + min: key === 'dehaze' ? 0 : -10, + max: 10, + step: 1, + display: v > 0 ? `+${v}` : String(v), + edited: v !== 0, + onChange: (n: number) => setMaskKnob({ [key]: Math.round(n) } as Partial), + onReset: () => setMaskKnob({ [key]: 0 } as Partial), + }; + }; + const maskRulers: SliderDef[] = !selMask ? [] : [ @@ -2391,6 +2464,12 @@ export function Workspace() { onChange: (v) => setMaskKnob({ saturation: Math.round(v) }), onReset: () => setMaskKnob({ saturation: 0 }), }, + maskKnobRow('highlights', 'HIGHLIGHT'), + maskKnobRow('shadows', 'SHADOW'), + maskKnobRow('whites', 'WHITE'), + maskKnobRow('blacks', 'BLACK'), + maskKnobRow('clarity', 'CLARITY'), + maskKnobRow('dehaze', 'DEHAZE'), ...(selMask.kind === 'radial' ? [ { @@ -2556,6 +2635,7 @@ export function Workspace() { // preset that was merely filed under a new name must not light up, // hence the name check too. active: recipe.id === (entry.recipe as Recipe)?.id && recipe.name === entry.name, + ...lookPreview({ ...(entry.recipe as Recipe), name: entry.name }), onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), })); case 'photos': { @@ -2770,7 +2850,7 @@ export function Workspace() { if (g === 'gradient') return gradientChips; return [ { key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} }, - ...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick).map((c) => + ...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick, g === 'style' ? simPreview : undefined).map((c) => // A hand-straightened frame is no longer on a clean quarter turn: the // angle chips let go the moment the fine angle leaves 0, so the strip // shows which of the two is actually steering the photo. @@ -2835,6 +2915,7 @@ export function Workspace() { key: `saved-${entry.id}`, label: entry.name, active: recipe.name === entry.name, + ...lookPreview({ ...(entry.recipe as Recipe), name: entry.name }), onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), })), ...created @@ -2844,6 +2925,7 @@ export function Workspace() { label: entry.name, active: recipe.id === entry.id, drag: entry.id, + ...lookPreview(entry), onClick: () => applyRecipe(entry), })), ...DEFAULT_RECIPES.map((preset) => ({ @@ -2851,6 +2933,7 @@ export function Workspace() { label: preset.name, active: recipe.name === preset.name && !recipe.isCustom, drag: preset.id, + ...lookPreview(preset), onClick: () => applyRecipe({ ...preset }), })), ]; diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index f2286c5..f9acc48 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -28,6 +28,11 @@ import { EXPOSURE_SKSL, GLOW_SKSL, CLARITY_SKSL, + CLARITY_BLUR_SKSL, + CLARITY_BLEND_SKSL, + CLARITY_GAIN, + DEHAZE_SKSL, + dehazeUniformArray, getToneUniforms, toneIsActive, toneUniformArray, @@ -37,7 +42,7 @@ import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/uti import { CURVE_SKSL, CURVE_LUT_SIZE, curveIsActive, curveLut } from '../../shared/utils/toneCurve'; import { healSkSL, healUniforms, readHeal } from '../../shared/utils/heal'; import { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic'; -import { gradientMaskSkSL, maskUniforms, readMasks } from '../../shared/utils/gradientMask'; +import { gradientMaskSkSL, maskUniforms, readMasks, masksHaveSpatial } from '../../shared/utils/gradientMask'; import { GRAIN_SKSL, HALATION_SKSL, @@ -158,6 +163,9 @@ let cinemaEffect: any = null; let glowEffect: any = null; let halationEffect: any = null; let curveEffect: any = null; +let clarityBlurEffect: any = null; +let clarityBlendEffect: any = null; +let dehazeEffect: any = null; function effects() { if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL); @@ -167,7 +175,21 @@ function effects() { if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL); if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL); if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL); - return { sharpenEffect, toneEffect, exposureEffect, cinemaEffect, glowEffect, halationEffect, curveEffect }; + if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL); + if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL); + if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL); + return { + sharpenEffect, + toneEffect, + exposureEffect, + cinemaEffect, + glowEffect, + halationEffect, + curveEffect, + clarityBlurEffect, + clarityBlendEffect, + dehazeEffect, + }; } // FX's HEAL is the one effect whose shader depends on the recipe rather than on @@ -199,13 +221,16 @@ function mosaicEffectFor(count: number): any { // FX's gradient masks are the third shader of that kind — the shapes and the // knobs are the recipe's, so it is built for the count it is handed and cached -// by count the same way (shared/utils/gradientMask.ts). -const maskEffects = new Map(); -function maskEffectFor(count: number): any { - let effect = maskEffects.get(count); +// by count the same way (shared/utils/gradientMask.ts). Two variants per count: +// the masks that carry CLARITY or DEHAZE read a second child (the frame's own +// blurred reference), the rest do not, and neither pays for the other. +const maskEffects = new Map(); +function maskEffectFor(count: number, spatial: boolean): any { + const key = `${count}:${spatial ? 1 : 0}`; + let effect = maskEffects.get(key); if (effect === undefined) { - effect = Skia.RuntimeEffect.Make(gradientMaskSkSL(count)) ?? null; - maskEffects.set(count, effect); + effect = Skia.RuntimeEffect.Make(gradientMaskSkSL(count, spatial)) ?? null; + maskEffects.set(key, effect); } return effect; } @@ -229,17 +254,6 @@ function convolvePaint(srcImage: any, amount: number): any { return { paint, shader, child }; } -// The convolution must sample the ALREADY GRADED pixels (RN runs it as the -// outermost filter of the draw), so the pass reads a snapshot of the surface -// and writes the sharpened result back over the same rect. -function convPass(canvas: any, surface: any, w: number, h: number, amount: number): void { - flush(surface); - const snap = surface.makeImageSnapshot(); - if (!snap) return; - drawConvolved(canvas, snap, w, h, amount); - snap.dispose(); -} - // Replaces the pixels under the rect with the convolved result. function drawConvolved(canvas: any, srcImage: any, w: number, h: number, amount: number): void { const built = convolvePaint(srcImage, amount); @@ -295,6 +309,162 @@ function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: num disposeAll([paint, filter, snap]); } +// --- spatial passes: CLARITY and DEHAZE ------------------------------------- +// +// Both of the scratchpad docs' algorithms (raw_parameter_processing... §3 and +// ki_n_tr_c_multiple_passes... entirely) compare a pixel against a blurred +// reference of its own surroundings. The reference is built once here, the doc's +// way: a separable pair of passes — 1x15 across, 15x1 down — writes two offscreen +// surfaces the screen never shows, so a pixel costs 30 reads instead of the 225 +// one 15x15 kernel would take. Offscreen surfaces rather than the doc's +// `rgba16float` textures because this renderer is 8-bit end to end (CanvasKit +// surfaces, see skiaShim.ts); the format is the only thing the doc and this file +// disagree about. + +// How far out the reference looks, as a fraction of the frame's width, so the +// same CLARITY is the same size of neighbourhood on the preview and on the file. +const CLARITY_BLUR_SPAN = 0.06; + +// One axis of the reference. `image` is the input, the result is a new image the +// caller owns. +function blurAxis(image: any, w: number, h: number, dx: number, dy: number): any | null { + const { clarityBlurEffect: effect } = effects(); + const surf = createSurface(w, h); + if (!surf || !effect) { + surf?.dispose(); + return null; + } + let child: any = null; + try { + child = image.makeShaderOptions( + Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None + ); + const shader = child ? effect.makeShaderWithChildren([dx, dy], [child]) : null; + if (!shader) return null; + const paint = Skia.Paint(); + paint.setShader(shader); + surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint); + flush(surf); + disposeAll([paint, shader]); + return surf.makeImageSnapshot() ?? null; + } finally { + if (child) disposeAll([child]); + surf.dispose(); + } +} + +// The frame's own blurred reference, B in both docs: what the pixel is compared +// against for CLARITY, and the patch average the dark channel is read out of for +// DEHAZE. Null only if the effect could not be built. +function spatialReference(surface: any, w: number, h: number): any | null { + flush(surface); + const snap = surface.makeImageSnapshot(); + if (!snap) return null; + try { + const step = Math.max(1, (w * CLARITY_BLUR_SPAN) / 15); + const horizontal = blurAxis(snap, w, h, step, 0); + if (!horizontal) return null; + try { + return blurAxis(horizontal, w, h, 0, step); + } finally { + horizontal.dispose(); + } + } finally { + snap.dispose(); + } +} + +// The doc's atmospheric light A, the "amount of scattered light" DEHAZE takes +// back out. Read off the frame the pass is about to correct, through one 32x32 +// draw rather than the histogram the doc describes: the Dark Channel Prior's own +// answer is the pixel whose DARKEST channel is brightest, so the 4096-pixel copy +// only has to be searched for that one. Null when the readback is unavailable — +// the caller then leaves the pass out rather than guessing a value. +function atmosphericLight(surface: any, w: number, h: number): [number, number, number] | null { + const n = 32; + const small = createSurface(n, n); + if (!small) return null; + let snap: any = null; + let paint: any = null; + try { + flush(surface); + snap = surface.makeImageSnapshot(); + if (!snap) return null; + paint = Skia.Paint(); + small.getCanvas().drawImageRect( + snap, + Skia.XYWHRect(0, 0, w, h), + Skia.XYWHRect(0, 0, n, n), + paint + ); + flush(small); + const px = small.getCanvas().readPixels(0, 0, { + width: n, + height: n, + colorType: Skia.ColorType.RGBA_8888, + alphaType: Skia.AlphaType.Unpremul, + colorSpace: Skia.ColorSpace.SRGB, + }); + if (!px) return null; + let best = -1; + let air: [number, number, number] = [1, 1, 1]; + for (let i = 0; i < n * n; i++) { + const r = px[i * 4] / 255; + const g = px[i * 4 + 1] / 255; + const b = px[i * 4 + 2] / 255; + const dark = Math.min(r, g, b); + if (dark > best) { + best = dark; + air = [r, g, b]; + } + } + return air; + } catch { + // No readback (a surface that refuses one, a shader that failed): DEHAZE + // stays off for this render. + return null; + } finally { + disposeAll([paint, snap].filter(Boolean) as SkDisposable[]); + small.dispose(); + } +} + +// An image as a child shader — what every effect here wants instead of a paint. +// The caller owns the result (the renderer's `own`). +function imageShaderChild(image: any): any { + return image.makeShaderOptions( + Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None + ); +} + +// Snapshot → clear → redraw through the shader the callback builds FROM that +// snapshot: the shape every pass that reads the frame it is about to overwrite +// uses (the negative blurs, the gradient masks, HEAL), in one place so none of +// them has to remember to clear first. The callback owns whatever children it +// puts in the shader; the snapshot is freed here. +function replaceThrough( + canvas: any, + surface: any, + w: number, + h: number, + shaderOf: (snap: any) => any +): void { + flush(surface); + const snap = surface.makeImageSnapshot(); + if (!snap) return; + try { + const shader = shaderOf(snap); + if (!shader) return; + canvas.clear(Skia.Color('transparent')); + const paint = Skia.Paint(); + paint.setShader(shader); + canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint); + disposeAll([paint, shader]); + } finally { + snap.dispose(); + } +} + // --- fonts / artwork -------------------------------------------------------- let faces: { typeface: any; iconTypeface: any } | null = null; @@ -584,10 +754,55 @@ export async function renderPhoto(input: RenderInput): Promise 0) { - convPass(canvas, surface, width, height, (adjustments.clarity / 10) * 0.8); + // 4b. DEHAZE and CLARITY — the frame-wide half of the two docs + // (raw_parameter_processing... §3, ki_n_tr_c_multiple_passes... in full). Both + // compare a pixel against a blurred reference of its own surroundings, so the + // reference is built once out of the frame the pass is about to overwrite + // (spatialReference, the doc's separable bilateral pair) and handed in as the + // effect's second child. DEHAZE runs first: it takes the scattered light out, + // and sharpening haze instead would only make it read as detail. + // + // The masks are read here rather than at 6d because a mask's CLARITY/DEHAZE + // asks for the same reference and the same atmospheric light, and the light is + // cheapest to read before the masks have graded the frame. + const masks = readMasks(adjustments.masks); + const maskSpatial = masksHaveSpatial(masks); + let air: [number, number, number] | null = null; + let airRead = false; + const airOf = () => { + if (!airRead) { + airRead = true; + air = atmosphericLight(surface, width, height); + } + return air; + }; + const dehazeAmount = (adjustments.dehaze ?? 0) / 10; + + if (dehazeAmount > 0 && dehazeEffect) { + replaceThrough(canvas, surface, width, height, (snap) => { + const a = airOf(); + const reference = spatialReference(surface, width, height); + if (!a || !reference) return null; + own(reference); + const shader = dehazeEffect.makeShaderWithChildren(dehazeUniformArray(a, dehazeAmount), [ + own(imageShaderChild(snap)), + own(imageShaderChild(reference)), + ]); + return shader ? own(shader) : null; + }); + } + + if (adjustments.clarity > 0 && clarityBlendEffect) { + replaceThrough(canvas, surface, width, height, (snap) => { + const reference = spatialReference(surface, width, height); + if (!reference) return null; + own(reference); + const shader = clarityBlendEffect.makeShaderWithChildren( + [(adjustments.clarity / 10) * CLARITY_GAIN], + [own(imageShaderChild(snap)), own(imageShaderChild(reference))] + ); + return shader ? own(shader) : null; + }); } else if (adjustments.clarity < 0) { const mistSigma = Math.abs(adjustments.clarity / 10) * 4; drawBlurred(canvas, surface, width, height, mistSigma); @@ -711,30 +926,22 @@ export async function renderPhoto(input: RenderInput): Promise { + const reference = maskSpatial ? spatialReference(surface, width, height) : null; + if (!effect || (maskSpatial && !reference)) return null; + own(reference); + const children = [own(imageShaderChild(snap))]; + if (reference) children.push(own(imageShaderChild(reference))); + const shader = effect.makeShaderWithChildren(maskUniforms(masks, width, height, a), children); + return shader ? own(shader) : null; + }); } // 6e. HEAL — the FX tab's dust brush (see heal.ts). It is the last thing @@ -765,7 +972,7 @@ export async function renderPhoto(input: RenderInput): Promise sRGB, the first three columns of rgb_cam +uniform float4 m1; +uniform float4 m2; +uniform float4 crop; // (y offset of this band, 0, 0, 0) + +float3 encode(float3 x) { + x = clamp(x, 0.0, 1.0); + return mix(x * 12.92, 1.055 * pow(x, float3(1.0 / 2.4)) - 0.055, step(float3(0.0031308), x)); +} + +half4 main(float2 pos) { + float4 p = raw.eval(float2(pos.x, pos.y - crop.x)); + float3 lin = clamp((p.rgb - black.rgb) * black.a, 0.0, 1.0) * mul.rgb; + float3 rgb = float3(dot(m0.xyz, lin), dot(m1.xyz, lin), dot(m2.xyz, lin)); + return half4(half3(encode(rgb)), 1.0); +} +`; + +// The extensions LibRaw is compiled for. Not `tif`: a TIFF in a camera folder is +// as likely to be an export as a RAW. +const RAW_EXT = [ + 'arw', 'srf', 'sr2', 'cr2', 'cr3', 'crw', 'nef', 'nrw', 'dng', 'orf', 'raf', + 'rw2', 'pef', 'srw', 'raw', 'rwl', '3fr', 'fff', 'iiq', 'mos', 'mrw', 'x3f', +]; + +export const RAW_ACCEPT = RAW_EXT.map((e) => `.${e}`).join(','); + +export function isRawName(name: string): boolean { + const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase(); + return name.includes('.') && RAW_EXT.includes(ext); +} + +export async function developRaw(bytes: Uint8Array): Promise { + const raw = new LibRaw(); + try { + // LibRaw copies the buffer it is handed, so the caller's bytes stay intact. + await raw.open(bytes as unknown as BufferSource, SETTINGS); + const meta = await raw.metadata(true); + const img = await raw.imageData(); + const cd = meta?.color_data; + if (!img || !cd?.cam_mul || !cd.rgb_cam) throw new Error('RAW has no colour data'); + + const { width: w, height: h } = img; + const data = img.data as Uint16Array; + if (!w || !h) throw new Error('RAW decoded to nothing'); + + const surface = Skia.Surface.MakeOffscreen(w, h) ?? Skia.Surface.Make(w, h); + if (!surface) throw new Error('no surface for the develop'); + const effect = Skia.RuntimeEffect.Make(RAW_DEVELOP_SKSL); + if (!effect) throw new Error('develop shader failed to compile'); + + const green = cd.cam_mul[1] || 1; + const mul = cd.cam_mul.map((v) => v / green); + const row = (i: number) => cd.rgb_cam[i].slice(0, 3); + const [r0, r1, r2] = [row(0), row(1), row(2)]; + + const bandH = Math.max(1, Math.min(h, Math.floor(BAND_PIXELS / w))); + const f32 = new Float32Array(w * bandH * 4); + for (let y0 = 0; y0 < h; y0 += bandH) { + const rows = Math.min(bandH, h - y0); + let o = 0; + for (let i = y0 * w * 3, end = (y0 + rows) * w * 3; i < end; i += 3) { + f32[o++] = data[i] / SAMPLE_MAX; + f32[o++] = data[i + 1] / SAMPLE_MAX; + f32[o++] = data[i + 2] / SAMPLE_MAX; + f32[o++] = 1; + } + const band = Skia.Image.MakeImage( + { width: w, height: rows, colorType: Skia.ColorType.RGBA_F32, alphaType: Skia.AlphaType.Unpremul }, + new Uint8Array(f32.buffer, 0, w * rows * 16), + w * 16 + ); + if (!band) throw new Error('band image failed'); + const child = band.makeShaderOptions( + Skia.TileMode.Clamp, + Skia.TileMode.Clamp, + Skia.FilterMode.Nearest, + Skia.MipmapMode.None + ); + const uniforms = new Float32Array([ + cd.black / SAMPLE_MAX, cd.black / SAMPLE_MAX, cd.black / SAMPLE_MAX, + SAMPLE_MAX / (cd.maximum - cd.black), + mul[0], mul[1], mul[2], 0, + r0[0], r0[1], r0[2], 0, + r1[0], r1[1], r1[2], 0, + r2[0], r2[1], r2[2], 0, + y0, 0, 0, 0, + ]); + const shader = effect.makeShaderWithChildren(uniforms, [child]); + const paint = Skia.Paint(); + paint.setShader(shader); + surface.getCanvas().drawRect(Skia.XYWHRect(0, y0, w, rows), paint); + surface.flush(); + paint.delete(); + shader.delete(); + child.delete(); + band.delete(); + } + + const jpeg = surface.makeImageSnapshot().encodeToBytes(Skia.ImageFormat.JPEG, 92); + surface.dispose(); + if (!jpeg?.length) throw new Error('develop produced no bytes'); + return jpeg; + } finally { + raw.dispose(); + } +} diff --git a/docker/frontend/src/engine/rawStore.ts b/docker/frontend/src/engine/rawStore.ts new file mode 100644 index 0000000..fa2e595 --- /dev/null +++ b/docker/frontend/src/engine/rawStore.ts @@ -0,0 +1,60 @@ +// The RAW the current photo came from, parked in the origin private file system +// so the studio can go back to it instead of asking for 30MB a second time (see +// native_raw_processing_opfs_architecture.md). One slot only: the studio holds +// one photo, and the previous RAW is stale the moment the next one lands. +// +// The session keeps the *developed* JPEG, so the ordinary reload path never +// touches this — it is the fallback for an empty IndexedDB, and the file a +// future full-resolution or different-settings develop would read. +// +// ponytail: OPFS's own sync-access handle would let LibRaw read in place, but +// LibRaw.open takes a BufferSource, so every path here reads a Blob anyway. +// Switch to createSyncAccessHandle if that API ever grows a worker-side reader. +const FILE = 'current_image.raw'; +const NAME = 'current_image.name'; + +async function opfs(name: string, create: boolean): Promise { + const root = await navigator.storage?.getDirectory?.(); + if (!root) return null; + return root.getFileHandle(name, { create }); +} + +async function write(name: string, data: BlobPart): Promise { + const fh = await opfs(name, true); + if (!fh) return; + const w = await fh.createWritable(); + await w.write(data); + await w.close(); +} + +export async function storeRaw(bytes: Uint8Array, fileName: string): Promise { + try { + await write(FILE, bytes as unknown as BufferSource); + await write(NAME, fileName); + } catch { + // No OPFS (private mode, older browser): the photo still develops, it just + // cannot be recovered after a reload. Nothing here is worth failing a load. + } +} + +export async function readRaw(): Promise<{ name: string; bytes: Uint8Array } | null> { + try { + const fh = await opfs(FILE, false); + if (!fh) return null; + const nameHandle = await opfs(NAME, false); + const name = nameHandle ? (await (await nameHandle.getFile()).text()).trim() : FILE; + return { name: name || FILE, bytes: new Uint8Array(await (await fh.getFile()).arrayBuffer()) }; + } catch { + return null; + } +} + +export async function clearRaw(): Promise { + try { + const root = await navigator.storage?.getDirectory?.(); + await root?.removeEntry(FILE).catch(() => undefined); + await root?.removeEntry(NAME).catch(() => undefined); + } catch { + // Absent is the desired state. + } +} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index dc1f926..a68f4a5 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -325,6 +325,7 @@ export const en: Dict = { 'err.noWebgl': 'The browser refused a WebGL context — this build needs WebGL to process photos (the CPU path is thousands of times slower).', 'err.decode': 'Could not read this image. Try another JPG or PNG.', + 'err.raw': 'Could not develop this RAW file. Try again, or use its JPG.', 'err.generic': 'Something went wrong: {msg}', 'err.offline': 'Could not reach the server.', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index de5e915..757972a 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -333,6 +333,7 @@ export const vi = { 'err.noWebgl': 'Trình duyệt không cấp được WebGL — bản dựng này cần WebGL để xử lý ảnh (chạy CPU chậm gấp hàng nghìn lần).', 'err.decode': 'Không đọc được ảnh này. Thử JPG hoặc PNG khác.', + 'err.raw': 'Không tráng được file RAW này. Thử lại, hoặc dùng bản JPG.', 'err.generic': 'Có lỗi xảy ra: {msg}', 'err.offline': 'Không gọi được máy chủ.', diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 080b94d..100fa3a 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -20,6 +20,11 @@ export interface ChipDef { // Present when the chip itself can be dragged somewhere: the payload the drop // target reads back (the recipe's id, for the FAVORITED rail button). drag?: string; + // HOVER PREVIEW: a look the pointer lays on the photo before any click. Only + // the chips that carry a whole look (a sim, a recipe) have one; the pair is + // always both or neither, so leaving the chip always takes it back off. + onHover?: () => void; + onHoverEnd?: () => void; onClick: () => void; } @@ -64,7 +69,16 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) { } : undefined } - onClick={chip.onClick} + onMouseEnter={chip.onHover} + onMouseLeave={chip.onHoverEnd} + // The click takes the preview off first: a click that applies the look + // (and closes the column the chip was in) would otherwise leave a + // preview nothing is left to take off — and every later knob would + // move the recipe under a photo still painted from the preview. + onClick={() => { + chip.onHoverEnd?.(); + chip.onClick(); + }} > {chip.color ? : null} {chip.label} diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index bf1ef46..3fd850d 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { useI18n } from '../i18n/I18nProvider'; import { MIN_CROP_FRAC, type CropRect, type GradientMask, type HealSpot, type MosaicSpot } from '../../shared/types'; import { bitmapReader, samplePixel } from '../engine/imageOps'; +import { RAW_ACCEPT } from '../engine/rawDevelop'; import { findHealSource } from '../../shared/utils/heal'; import { MASK_DEFAULT_FEATHER, MASK_MIN } from '../../shared/utils/gradientMask'; import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush'; @@ -2048,7 +2049,7 @@ export function ImageStage({ { const file = e.target.files?.[0]; diff --git a/docker/frontend/vite.config.ts b/docker/frontend/vite.config.ts index 8c5d509..abbf1f8 100644 --- a/docker/frontend/vite.config.ts +++ b/docker/frontend/vite.config.ts @@ -23,6 +23,14 @@ export default defineConfig({ // runtime from /wasm/, never bundled. chunkSizeWarningLimit: 9000, }, + optimizeDeps: { + // libraw-wasm starts its own worker with `new URL('./worker.js', + // import.meta.url)`. Pre-bundling moves the module into .vite/deps/, where + // that sibling does not exist and the worker never loads. Excluding it keeps + // the package's own relative layout; the production build resolves it + // normally either way. + exclude: ['libraw-wasm'], + }, server: { port: 5173, proxy: { '/api': process.env.API_ORIGIN ?? 'http://localhost:3000' },