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