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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user