web: import the camera's RAW, and grade it like the phone

The studio took JPEG, PNG and HEIC and nothing else, so a photographer's own
negatives never reached it. A RAW now loads the way any other file does —
`isRawName` reads the extension off a 24-entry list, the file goes into OPFS
under one slot (`current_image.raw`, beside `current_image.name`, so a reload
finds it again) and `rawDevelop` runs it through LibRaw-wasm: half size, 16-bit
output, camera white balance and the camera's own 3x3 matrix, in bands of 2M
pixels so a 30MB file never holds a second copy of itself. `example-sony.ARW`
(30.3MB) lands as a 3120x2084 picture, no page error.

DEHAZE joins the FX tab, where Lightroom keeps it: a chip off the same
PARAM_DEFS entry (`dehaze`, 0..10) so nothing new renders chips, and the pass is
the dark channel prior — `atmosphericLight` reads A off a 32x32 draw of the
photo, `DEHAZE_SKSL` takes omega up to 0.95 over a floor of 0.1 — measured at
71.8% of the stage's pixels moved between 0 and 10.

The gradient mask grows the six knobs the phone's has: HIGHLIGHT, SHADOW,
WHITE, BLACK, CLARITY and DEHAZE. The mask's falloff is a smoothstep rather than
a line, and CLARITY/DEHAZE inside a mask get a blurred copy of the photo plus
the air A as a second child of the mask shader — so a mask's clarity is clarity
and not a flat brightness lift. The column shows all nine rulers; CLARITY 9
moves 42.2% of the stage, DEHAZE 9 moves 27.9%.

CLARITY stops reading the whole photo per pixel: the single pass that sampled a
15x15 box 225 times is now the three passes the same math wants — 1x15, then
15x1, then a blend, `orig + (orig - B) * 3.2` — about 30 reads. Both signs work
(77.4% of the stage moves at +10, 79.6% at -10), and the negative branch keeps
its mist as it was.

The pointer reviews a look before it is taken: resting on a PHOTO STYLE chip or
a recipe chip lays that look on the photo while it stays there and gives it back
the moment it leaves — byte-identical, measured on four of them (24.9%, 23.8%,
24.5%, 25.3% of the stage moves on, 0.00% off) — while the recipe, the UNDO
stack and the session stay on the look the click left. A hovered look brings its
colour alone: the masks, the dust spots and the mosaic of the photo being edited
ride along, or a pointer crossing a chip row would rub them off. A PRO sim is
left out, since a hover that showed its look would hand over what the click
gates.

Probes: e2e-raw-verify, e2e-dehaze-mask, e2e-mask-verify, e2e-clarity-verify,
e2e-hover-preview2.
This commit is contained in:
2026-09-26 18:17:18 +07:00
parent 178bc78bbb
commit 97bdf605e2
15 changed files with 835 additions and 96 deletions
+7
View File
@@ -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",
+1
View File
@@ -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"
+16
View File
@@ -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
+112 -29
View File
@@ -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;
}
`;
+15
View File
@@ -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',
@@ -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
+101 -18
View File
@@ -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<string | null>(() => 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<Recipe | null>(null);
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
const [cropRect, setCropRect] = useState<CropRect>(() => 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<ChipDef, 'onHover' | 'onHoverEnd'> => ({
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<ChipDef, 'onHover' | 'onHoverEnd'> => {
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>
): 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<GradientMask>),
onReset: () => setMaskKnob({ [key]: 0 } as Partial<GradientMask>),
};
};
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 }),
})),
];
+254 -47
View File
@@ -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<number, any>();
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<string, any>();
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<RenderResult | nu
canvas.drawImage(skImage, 0, 0, paint);
}
// 4b. CLARITY (positive) — convolution outside the denoise blur, the same
// nesting RN builds with MakeMatrixConvolution(input = previous filter).
if (adjustments.clarity > 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<RenderResult | nu
// reads what the first left. It sits here, after the frame-wide grade and
// before HEAL, because a local adjustment is part of the look and not a
// repair: the pixels a repair borrows are meant to carry it already.
const masks = readMasks(adjustments.masks);
// Same shape as the passes below — the shader reads the frame it is about to
// overwrite — and, when a mask carries CLARITY or DEHAZE, the frame's own
// blurred reference and the atmospheric light come in exactly as they did for
// the frame-wide pair at 4b.
if (masks.length) {
const effect = maskEffectFor(masks.length);
flush(surface);
const snap = own(surface.makeImageSnapshot());
const child = snap
? own(
snap.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
)
: null;
const shader =
effect && child
? own(effect.makeShaderWithChildren(maskUniforms(masks, width, height), [child]))
: null;
if (shader) {
// Same shape as the two passes below: the shader reads the frame it is
// about to overwrite, so the surface is snapshotted, cleared and redrawn.
canvas.clear(Skia.Color('transparent'));
const maskPaint = own(Skia.Paint());
maskPaint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), maskPaint);
}
const effect = maskEffectFor(masks.length, maskSpatial);
const a = maskSpatial ? airOf() : null;
replaceThrough(canvas, surface, width, height, (snap) => {
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<RenderResult | nu
: null;
if (shader) {
// Snapshot, clear, redraw — the pass reads the frame it is about to
// overwrite (the same shape convPass uses for CLARITY).
// overwrite (see replaceThrough).
canvas.clear(Skia.Color('transparent'));
const healPaint = own(Skia.Paint());
healPaint.setShader(shader);
+153
View File
@@ -0,0 +1,153 @@
// RAW → JPEG on the client, so a camera's own file opens in the studio without
// a DNG converter in the middle (see native_raw_processing_opfs_architecture.md).
//
// LibRaw demosaics in its own worker; what comes back is linear camera data,
// which this file turns into the sRGB the rest of the pipeline expects. The
// whole thing is measured against a real 26MP Sony ARW — the settings below are
// the ones that gave the correct colours there:
// - noAutoScale + useCameraWb:false + noAutoBright + gamm [1,1] keep LibRaw out
// of white balance and tone, so `cam_mul` and `rgb_cam` can be applied here
// exactly once.
// - halfSize: 26MP → 6.5MP. ponytail: drop it for full resolution if a user
// ever asks for a print from the RAW; the develop pass is the whole cost.
//
// The band loop exists because a single Float32 copy of the whole plane would be
// ~100MB. Each band is decoded, normalised and drawn before the next is read.
// ponytail: the develop itself is one full-resolution shader draw per band, on
// the main thread (Skia is not available in the RAW worker). Move it to a worker
// with an OffscreenCanvas if the develop ever blocks the UI visibly.
import LibRaw from 'libraw-wasm';
import { Skia } from './skiaShim';
// What `imageData()` returns for the settings below: 16-bit, 3 channels, with
// the black level still in it — hence the two normalisations in the shader.
const SETTINGS = {
halfSize: true,
outputBps: 16,
outputColor: 0,
noAutoScale: true,
useCameraWb: false,
noAutoBright: true,
gamm: [1, 1] as [number, number],
userQual: 3,
highlight: 0,
};
// 16-bit samples, but everything below is [0,1] — Skia reads an F32 image as
// already-normalised colour, and a raw 0..65535 plane comes back black.
const SAMPLE_MAX = 65535;
// Pixels per band, held under the point where the Float32 copy dominates the
// memory the page is allowed to use.
const BAND_PIXELS = 2_000_000;
const RAW_DEVELOP_SKSL = `
uniform shader raw;
uniform float4 black; // (black level, 1 / (white level - black level))
uniform float4 mul; // cam_mul, green-normalised
uniform float4 m0; // camera -> 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<Uint8Array> {
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();
}
}
+60
View File
@@ -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<FileSystemFileHandle | null> {
const root = await navigator.storage?.getDirectory?.();
if (!root) return null;
return root.getFileHandle(name, { create });
}
async function write(name: string, data: BlobPart): Promise<void> {
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<void> {
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<void> {
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.
}
}
+1
View File
@@ -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.',
+1
View File
@@ -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ủ.',
+15 -1
View File
@@ -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 ? <span className="chip-dot" data-color={chip.color} style={{ background: chip.color }} /> : null}
{chip.label}
+2 -1
View File
@@ -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({
<input
ref={inputRef}
type="file"
accept="image/*"
accept={`image/*,${RAW_ACCEPT}`}
hidden
onChange={(e) => {
const file = e.target.files?.[0];
+8
View File
@@ -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' },