web: BLACK keeps the picture's texture and not the base it was read off, clarity stops the blur at an edge instead of at a colour, and a backup folder that refuses is handed back to the picker
BLACK at full deflection returned a soft picture, and on a monochrome frame it
returned the blurred base outright. The tone pass reads the ramp at the
neighbourhood's base and then rebuilds the pixel, and it rebuilt it by the RATIO
the base had moved by — `Base' * (Input / Base)`, the other half of
fix_shadow.md's decomposition. A ratio is a gain, and that gain is a function of
the neighbourhood: the knob that takes the base toward zero scales every pixel's
detail by the same coefficient, so the knob that darkens the frame takes its
texture with it. Measured on lightroom_shadow.jpg at 1024px through tone-sim.mjs,
the pass in plain JS with the shader's own constants: at BLACK -100 the finest
gradient came back at 0.75 of the input under the ratio and at 0.98 under the
sum, while the frame darkened the same either way (mean 0.416 to 0.359 both). A
monochrome stock is the whole frame of that error, because all three channels ARE
the pixel's luma there, which is why the picture came back as the base — soft,
and short of every edge it had.
The reconstruction is `Base' + Detail`, ADDED and not scaled, and it costs
nothing where there is no move to make: with every knob on zero the ramp at the
base IS the base, so the difference is exactly zero and the pass is the identity
however coarse the base is. A caller that hands in no neighbourhood at all — a
mask — hands in the pixel's own image as its base and gets the global move back,
which is what the ratio gave it too. `o` is held inside the cube before the
detail is added, so a neighbourhood the ramp has pushed under the floor keeps the
structure around it instead of carrying its pedestal down onto every pixel in the
region. The bright side of the same move is untouched: the lift is still the
neighbourhood's, and at SHADOW +100 the band above the lifted knot still keeps
0.81 of its spread where the global move kept 0.42.
CLARITY drew a light stroke down every contour, and the reason was in what the
blur called a neighbour. The range weight was the colour difference,
`exp(-dot(d, d) * 24)`, which is loose on any coloured edge — two sides of a hair,
a branch or a rail can share a red and differ in green — so the reference reached
across the edge, and the reference is exactly what the blend subtracts. The wider
it reaches, the more a contour reads as detail. It reads LUMINANCE now, one
decision per tap at the doc's own scale (`CLARITY_RANGE_SIGMA` = 0.04), so an edge
of any hue stops the blur dead.
The blend moved the three channels by their own differences, which is what
coloured fringing along every contour was, and the amount it moved them by was
the MASK's `CLARITY_GAIN` borrowed for a different child. It moves LUMINANCE now
— one value carries the whole pixel back with it, so hue is untouchable and skin
does not go sallow at the top of the knob — under the doc's midtone weight
M(L) = 4L(1-L), which deepens the greys a picture is made of and leaves the burnt
ends and the deepest shadows where they are. The positive side's gain lives
inside the shader (4.5, raised from the doc's 1.8 because the range weight above
reaches less far and carries less detail): clarity-halo.mjs, which measures the
pass pushing a pixel outside the range of its own neighbourhood, reads a bright
stroke of 55.3/255 on lightroom_shadow.jpg and 54.3/255 on DSCF1701.JPG at +10,
against the old pass's 145 and 127 at the same knob — and 8.0 already reads 98, so
the knob does not need to go further to keep the flat areas moving.
`exportEngine` passes the knob's own units now, `[clarityKnob / 10]`, since the
gain and the sign are the shader's business and the mask's gain is not the
frame's.
And a backup folder the browser had stopped letting the page write to wrote
nothing and said so, once, with no way back: a permission outlives the tab only
while the tab does, so the row kept reading a permission of a moment ago while
the write went to the disk without one. A refusal that names the permission, or
the folder that is not there, now goes through the picker ONCE — the picker is
the only thing that hands a folder back — and the run is repeated; anything else
is the folder itself saying no, and is not asked twice. The sentence on the
screen is one line over a strip of photographs and cannot carry a reason, so the
reason goes to the console and a word of it into the note (`{why}`, the name the
browser gave the refusal and never a stack), which is the whole of what tells a
folder that was moved from a permission that lapsed. Both dictionaries learn the
word.
Checked: `tsc --noEmit` clean; `tone-base-check.mjs` and `highlight-knee-check.mjs`
updated to the new reconstruction and passing; `tone-sim.mjs` on
lightroom_shadow.jpg at 1024px for the numbers above; `clarity-halo.mjs` for the
stroke.
This commit is contained in:
@@ -172,7 +172,7 @@ assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again —
|
||||
// Nor a smoothstep through the knots: an S-curve bends the ramp by six code
|
||||
// values in the quarter-tones with every knob on zero, and this pass also runs
|
||||
// for the stock split tones and for DR alone.
|
||||
assert.match(sksl, /float o = base\n \+ blackA \* toneBump\(base, 0\.00, 0\.50\)\n \+ shadowA \* toneBump\(base, 0\.25, 0\.25\)\n \+ highA \* toneBump\(base, 0\.75, 0\.25\)\n \+ whiteA \* toneBump\(base, 1\.00, 0\.50\);/);
|
||||
assert.match(sksl, /float o = clamp\(base\n \+ blackA \* toneBump\(base, 0\.00, 0\.50\)\n \+ shadowA \* toneBump\(base, 0\.25, 0\.25\)\n \+ highA \* toneBump\(base, 0\.75, 0\.25\)\n \+ whiteA \* toneBump\(base, 1\.00, 0\.50\), 0\.0, 1\.0\);/);
|
||||
assert.doesNotMatch(sksl, /float lin\(/, 'the straight segments are back — a knot is an angle in a tone curve');
|
||||
// The guard. Two bumps share each half of the ramp and their steep sides can land
|
||||
// on the same stretch, so the two amplitudes of a half are held under a total
|
||||
@@ -184,11 +184,16 @@ assert.match(sksl, /float holdHi = 6\.1584 \* abs\(highA\) \+ 3\.0792 \* abs\(wh
|
||||
assert.match(sksl, /float kLo = holdLo > 1\.0 \? 1\.0 \/ holdLo : 1\.0;/);
|
||||
assert.match(sksl, /float kHi = holdHi > 1\.0 \? 1\.0 \/ holdHi : 1\.0;/);
|
||||
assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again');
|
||||
// ...and the pixel rides the ratio of that: the DETAIL layer, kept whole. The
|
||||
// ramp's own luma is not what is handed to the rebuild any more — the pixel's is,
|
||||
// scaled by the neighbourhood's gain — or the move would be global again and the
|
||||
// ...and the pixel rides the neighbourhood's move with its own difference from
|
||||
// it: Base' + Detail, ADDED and not scaled. Multiplying by the gain o / base is
|
||||
// what this pass used to do and it takes the detail away exactly where a knob
|
||||
// takes the base down — at BLACK -100 the picture came back soft, and on a
|
||||
// monochrome frame (all three channels on the pixel's luma) it came back as the
|
||||
// blurred base itself. The ramp's own luma is still not what is handed to the
|
||||
// rebuild — the neighbourhood's is — or the move would be global again and the
|
||||
// band above SHADOW would be drawn flat, which is the whole bug.
|
||||
assert.match(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/);
|
||||
assert.match(sksl, /float target = o \+ \(t - base\);/);
|
||||
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
|
||||
assert.match(sksl, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/);
|
||||
assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve');
|
||||
assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again');
|
||||
@@ -472,12 +477,13 @@ function lightMove(rgb, t, o) {
|
||||
// The rebuild, with the base layer the shader now draws the ramp through. `base`
|
||||
// defaults to the pixel itself — the degenerate call, and the one a mask makes —
|
||||
// which lands `target` back on `o` and is the move this function had before there
|
||||
// was a base at all.
|
||||
// was a base at all. Away from that the pixel's own DIFFERENCE from the base is
|
||||
// ADDED to the neighbourhood's new luma (Base' + Detail), never scaled by it.
|
||||
function rebuild(rgb, k, base) {
|
||||
const t = lumaOf(rgb);
|
||||
const b = base ?? t;
|
||||
const o = ramp(b, k).o;
|
||||
const target = clamp01(b > 0.0004 ? (o * t) / b : t);
|
||||
const target = clamp01(o + (t - b));
|
||||
const out = lightMove(rgb, t, target);
|
||||
return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target };
|
||||
}
|
||||
@@ -576,12 +582,17 @@ for (const [rgb, knobs] of [
|
||||
// own spread at this deflection (0.50 when the ramp was
|
||||
// straight segments, the bump adds a little of the lift
|
||||
// back into the band).
|
||||
// read at the base every pixel of ONE neighbourhood takes the same gain,
|
||||
// o(base)/base, so the texture inside it rides out whole,
|
||||
// and the same lift lands on the pixels either way.
|
||||
// read at the base every pixel of ONE neighbourhood takes the same move,
|
||||
// o(base) - base, and its own difference from the base is
|
||||
// added to it, so the texture inside the region comes out
|
||||
// at ITS OWN size and the same lift lands on the pixels
|
||||
// either way.
|
||||
//
|
||||
// Those are the two numbers the live probe reads off the deployed bundle (0.57
|
||||
// before, 0.78 after, over this frame); this is the same claim in arithmetic.
|
||||
// The spread here is kept WHOLE and not at 0.78 — this twin's band sits on one
|
||||
// base, where the reconstruction is exact by construction; on a real frame the
|
||||
// band spans many neighbourhoods and the live number is the one to read.
|
||||
const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31));
|
||||
const spread = (xs) => Math.max(...xs) - Math.min(...xs);
|
||||
const bandBase = band.reduce((a, b) => a + b, 0) / band.length;
|
||||
@@ -590,23 +601,27 @@ const movedLocally = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).cla
|
||||
near(spread(movedGlobally) / spread(band), 0.480832, 'the pixel-read ramp no longer draws its own band flat');
|
||||
const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase;
|
||||
assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`);
|
||||
close(spread(movedLocally) / spread(band), bandGain, 'the band did not keep its texture under the lift');
|
||||
close(spread(movedLocally) / spread(band), 1, 'the band lost its texture under the lift — the detail is being scaled again');
|
||||
assert.ok(
|
||||
Math.abs(movedLocally[0] - band[0] - (ramp(bandBase, { sh: 1 }).o - bandBase)) < 1e-12,
|
||||
'the lift is no longer the neighbourhood’s',
|
||||
);
|
||||
assert.ok(
|
||||
spread(movedLocally) / spread(movedGlobally) > 1.5,
|
||||
`the base is not earning its keep: ${spread(movedLocally) / spread(movedGlobally)}x the global move's spread`
|
||||
);
|
||||
// The gain belongs to the NEIGHBOURHOOD, not to the pixel: two pixels of one base
|
||||
// The move belongs to the NEIGHBOURHOOD, not to the pixel: two pixels of one base
|
||||
// take the same one however far apart they sit, which is exactly what leaves the
|
||||
// difference between them standing. (Read at the pixel, the gain would be the
|
||||
// pixel's own o / t — the slope of the curve where the pixel happens to be.)
|
||||
// difference between them standing. (Read at the pixel, the move would be the
|
||||
// pixel's own o - t — the curve where the pixel happens to be.)
|
||||
for (const [lo, hi] of [[0.28, 0.44], [0.30, 0.48]]) {
|
||||
const a = rebuild([lo, lo, lo], { sh: 1 }, 0.38).clamped[0] / lo;
|
||||
const b = rebuild([hi, hi, hi], { sh: 1 }, 0.38).clamped[0] / hi;
|
||||
close(a, b, 'the gain is the pixel’s again, not the neighbourhood’s');
|
||||
const a = rebuild([lo, lo, lo], { sh: 1 }, 0.38).clamped[0] - lo;
|
||||
const b = rebuild([hi, hi, hi], { sh: 1 }, 0.38).clamped[0] - hi;
|
||||
close(a, b, 'the move is the pixel’s again, not the neighbourhood’s');
|
||||
}
|
||||
// Every knob on zero is the identity through the base path too, whatever base is
|
||||
// handed in — the ramp at b IS b, so the ratio is 1 — and so is a caller whose
|
||||
// base is its own pixel (bx = 0, the mask, the nine identical taps).
|
||||
// handed in — the ramp at b IS b, so the difference is 0 — and so is a caller
|
||||
// whose base is its own pixel (bx = 0, the mask, the nine identical taps).
|
||||
for (const b of [0.01, 0.1, 0.35, 0.7, 0.99])
|
||||
for (const rgb of [...colourCases, ...greyCases]) {
|
||||
const g = rebuild(rgb, {}, b);
|
||||
|
||||
@@ -97,12 +97,15 @@ const bump = (b, knot, halfWidth) => {
|
||||
return v * v;
|
||||
};
|
||||
const ramp = (b) => b + TONE_ANCHOR * 0.5 * bump(b, 0.25, 0.25);
|
||||
|
||||
const srcValue = 128; // the pixel: 0.501961 encoded
|
||||
const baseValue = 76; // its neighbourhood, darker: 0.298039
|
||||
const t = srcValue / 255;
|
||||
const b = baseValue / 255;
|
||||
const expected = Math.round(255 * ((ramp(b) * t) / b));
|
||||
// The neighbourhood's new luma, plus the pixel's own DIFFERENCE from the base —
|
||||
// Base' + Detail, the reconstruction the shader emits (see the note in
|
||||
// toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and
|
||||
// it is what took the texture out of the frame at BLACK -100.
|
||||
const expected = Math.round(255 * (Math.min(1, Math.max(0, ramp(b))) + (t - b)));
|
||||
|
||||
const got = render(srcValue, baseValue);
|
||||
assert.ok(
|
||||
|
||||
@@ -54,20 +54,28 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
//
|
||||
// So the curve is drawn through what the frame holds AROUND the pixel — a coarse
|
||||
// edge-aware blur of the luma, TONE_BASE_RADIUS of the frame wide (the
|
||||
// fix_shadow.md decomposition, Base x Detail) — and the pixel then rides the
|
||||
// ratio: it takes the neighbourhood's gain `o / base` and keeps the difference
|
||||
// from it. Base moves, detail stays: the same lift, on the same pixels, with the
|
||||
// texture inside the region left standing instead of drawn flat. Full deflection
|
||||
// on the same frame keeps 0.78 of the band's spread where the global move kept
|
||||
// 0.57 (shadow-live.mjs, before and after, over the deployed pass; shadow-band.py,
|
||||
// fix_shadow.md decomposition, Base x Detail) — and the neighbourhood's new luma
|
||||
// is added to the pixel's own difference from it: Base' + Detail. Base moves,
|
||||
// detail keeps its size: the same lift, on the same pixels, with the texture
|
||||
// inside the region left standing instead of drawn flat. Full deflection on the
|
||||
// same frame keeps 0.78 of the band's spread where the global move kept 0.57
|
||||
// (shadow-live.mjs, before and after, over the deployed pass; shadow-band.py,
|
||||
// the numpy twin of this maths, put it at 0.78 to 0.80).
|
||||
//
|
||||
// The detail used to ride the gain instead — Base' * (Input / Base), the other
|
||||
// half of the same decomposition — and that is the reconstruction a tonal knob
|
||||
// must not use: the gain is a function of the neighbourhood, so the detail is
|
||||
// scaled by how dark or bright the region is, and a knob that takes the base
|
||||
// toward zero takes the texture with it. BLACK -100 did exactly that (see the
|
||||
// note in toneRamp), and on a monochrome frame, where all three channels ARE
|
||||
// the pixel's luma, it returned the blurred base outright.
|
||||
//
|
||||
// It costs nothing where there is no lift to make: with every knob on zero the
|
||||
// ramp at base IS base, so the ratio is exactly 1 and the pass is the identity
|
||||
// however coarse the base is. A caller that hands in no neighbourhood at all
|
||||
// (a mask, which has none) hands in the pixel's own image as the base and gets
|
||||
// the global move back — which is why the shared maths can take the base as an
|
||||
// argument and mean the same thing in both places.
|
||||
// ramp at base IS base, so the difference is exactly zero and the pass is the
|
||||
// identity however coarse the base is. A caller that hands in no neighbourhood
|
||||
// at all (a mask, which has none) hands in the pixel's own image as the base and
|
||||
// gets the global move back — which is why the shared maths can take the base as
|
||||
// an argument and mean the same thing in both places.
|
||||
//
|
||||
// Between two knots the ramp is drawn STRAIGHT, and that is deliberate: a
|
||||
// smoothstep there is an S-curve through the knots, so it bends the ramp by up
|
||||
@@ -317,21 +325,37 @@ vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float w
|
||||
shadowA *= kLo;
|
||||
highA *= kHi;
|
||||
whiteA *= kHi;
|
||||
// The ramp is read at the BASE, so the curve's move is the neighbourhood's and
|
||||
// the pixel keeps its own difference from it — the ratio below is the whole of
|
||||
// the detail layer (fix_shadow.md's Reconstructed = Base' * (Input / Base)),
|
||||
// with lightMove and its caps doing the reconstruction the way every other
|
||||
// brightness move in this file is made. The pixel's own luma still travels as
|
||||
// t: it is the value that is being rebuilt, and the one the caps read.
|
||||
float o = base
|
||||
// The ramp is read at the BASE, so the curve's move is the neighbourhood's,
|
||||
// and the pixel keeps its own DIFFERENCE from that base — fix_shadow.md's
|
||||
// Base' + Detail, added and not scaled. The ratio Base' * (Input / Base) was
|
||||
// the first cut and it is what broke BLACK: multiplying by the neighbourhood's
|
||||
// gain o / base scales the detail by that gain, so the knob that takes o toward
|
||||
// zero takes the picture's texture with it. Measured on the sample frame at
|
||||
// BLACK -100 (tone-sim, the pass in plain JS at 1024px): the finest gradient
|
||||
// came back at 0.75 of the input under the ratio and at 0.98 under the sum,
|
||||
// while the frame darkened the same either way (mean 0.416 -> 0.359 both). On a
|
||||
// monochrome stock every channel IS the pixel's luma, so the ratio's error is
|
||||
// the whole frame there: the pass returned the blurred BASE, which is the soft
|
||||
// picture the knob was reported for. The bright side of the same move is kept —
|
||||
// the lift is still the neighbourhood's, and the band above a lifted knot still
|
||||
// keeps its spread (0.81 of it at SHADOW +100, where the global move kept 0.42).
|
||||
//
|
||||
// The pixel's own luma still travels as t: lightMove rebuilds the colour from
|
||||
// it, and it is the value the caps read.
|
||||
//
|
||||
// o is held inside the cube BEFORE the detail is added: a base the ramp has
|
||||
// pushed under the floor is a neighbourhood whose black point is below zero,
|
||||
// and clipping o first keeps the structure around it instead of carrying the
|
||||
// pedestal down onto every pixel in the region.
|
||||
float o = clamp(base
|
||||
+ blackA * toneBump(base, 0.00, 0.50)
|
||||
+ shadowA * toneBump(base, 0.25, 0.25)
|
||||
+ highA * toneBump(base, 0.75, 0.25)
|
||||
+ whiteA * toneBump(base, 1.00, 0.50);
|
||||
// Below the pedestal there is no base worth a ratio (the same floor lightMove
|
||||
// holds its own scale under); the pixel is left where the ramp put its own
|
||||
// luma, which is what a caller with no neighbourhood hands in anyway.
|
||||
float target = base > 0.0004 ? o * t / base : t;
|
||||
+ whiteA * toneBump(base, 1.00, 0.50), 0.0, 1.0);
|
||||
// A caller with no neighbourhood of its own (a mask) hands in the pixel as its
|
||||
// base, and the difference is then exactly zero: the target is the ramp at t,
|
||||
// the global move, which is what the ratio gave it too.
|
||||
float target = o + (t - base);
|
||||
return lightMove(c, t, clamp(target, 0.0, 1.0));
|
||||
}
|
||||
// The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the
|
||||
@@ -651,11 +675,29 @@ export function clarityUniforms(a: number, pxX: number, pxY: number) {
|
||||
// `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.
|
||||
//
|
||||
// The range weight reads LUMINANCE, not the colour difference the black-halo
|
||||
// trade first used. A colour difference is loose on any coloured edge — two
|
||||
// sides of it can share a red and differ in green — so the reference blurred
|
||||
// across hair, branches and every rail, and that reference is exactly what the
|
||||
// blend subtracts: the wider the reference reaches, the more a contour reads as
|
||||
// detail, and clarity drew a light stroke down each one. On luminance the weight
|
||||
// is one decision per tap, at the doc's own scale CLARITY_RANGE_SIGMA, and an
|
||||
// edge of any hue stops the blur dead. (A four-times downsample of the source,
|
||||
// md section D, is not worth it at 15 taps: measure the cost of the full-res
|
||||
// pair first — ponytail: add the 4x pyramid only if a phone profile shows the
|
||||
// two 1x15 passes as the frame's cost.)
|
||||
export const CLARITY_BLUR_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float2 dir;
|
||||
const float3 CLARITY_LUM = vec3(0.2126, 0.7152, 0.0722);
|
||||
// One tap's luminance may sit this far from the centre's and still be counted:
|
||||
// a tenth of the range. Loose enough that a smooth gradient still averages,
|
||||
// tight enough that a contour one pixel wide is a wall to the blur.
|
||||
const float CLARITY_RANGE_SIGMA = 0.04;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
float lc = dot(c.rgb, CLARITY_LUM);
|
||||
vec3 sum = c.rgb;
|
||||
float total = 1.0;
|
||||
for (int i = 1; i <= 15; i++) {
|
||||
@@ -663,10 +705,10 @@ vec4 main(vec2 xy) {
|
||||
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);
|
||||
float d1 = (dot(a1.rgb, CLARITY_LUM) - lc) / CLARITY_RANGE_SIGMA;
|
||||
float d2 = (dot(a2.rgb, CLARITY_LUM) - lc) / CLARITY_RANGE_SIGMA;
|
||||
float r1 = exp(-d1 * d1);
|
||||
float r2 = exp(-d2 * d2);
|
||||
sum += g * (r1 * a1.rgb + r2 * a2.rgb);
|
||||
total += g * (r1 + r2);
|
||||
}
|
||||
@@ -674,27 +716,60 @@ vec4 main(vec2 xy) {
|
||||
}
|
||||
`;
|
||||
|
||||
// CLARITY, pass 3 of the doc's architecture: the frame against its own blurred
|
||||
// reference — `orig + (orig - B) * strength` above zero, the mix back toward B
|
||||
// below it. One reference, one pass, both directions of one knob: a NEGATIVE
|
||||
// CLARITY is the positive one's soften, not a second kind of blur picked for the
|
||||
// sign (that mist had another radius than the reference the positive side reads,
|
||||
// so -10 and +10 were two different neighbourhoods and a MASK's CLARITY could
|
||||
// not be the frame's own move). Clamped because a file cannot hold more than
|
||||
// white. Runs on the ENCODED pixels like every other grade here (only
|
||||
// CLARITY, pass 3 of the doc's architecture: the frame's detail against its own
|
||||
// blurred reference. Base is the reference B, Detail is the frame minus B, and
|
||||
// the pass returns B + Detail * (1 + amount * gain * midtone). Above zero the
|
||||
// detail comes back amplified; below it the knob is the mix back toward B, so
|
||||
// NEGATIVE CLARITY is the positive one's soften and not a second blur picked for
|
||||
// the sign (that mist had another radius than the reference the positive side
|
||||
// reads, so -10 and +10 were two different neighbourhoods and a MASK's CLARITY
|
||||
// could not be the frame's own move). 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.
|
||||
//
|
||||
// The move is made ON LUMINANCE and then handed back to all three channels by
|
||||
// one scale, which is the trade's own rule C. Detail is what the eye reads as
|
||||
// structure, but it is not per-channel: sharpen red against a red-and-green edge
|
||||
// and red alone overshoots, which is what coloured fringing along every contour
|
||||
// was. One luminance value carries the whole pixel back with it, so hue is
|
||||
// untouchable — skin does not go sallow at the top of the knob, and a saturated
|
||||
// red or a cyan shadow keeps its ratio. The midtone weight is the doc's
|
||||
// M(L) = 4L(1-L): 0 at black and at white, 1 in the middle, so the knob deepens
|
||||
// the greys a picture is made of and leaves the burnt ends and the deepest
|
||||
// shadows where they are, which is also where a halo would otherwise show worst.
|
||||
export const CLARITY_BLEND_SKSL = `
|
||||
uniform shader original;
|
||||
uniform shader blurred;
|
||||
uniform float strength;
|
||||
const float3 CLARITY_LUM = vec3(0.2126, 0.7152, 0.0722);
|
||||
// md section 5's 1.8, raised to 4.5: the range weight above now stops the blur
|
||||
// at a real edge, so the reference reaches less far and carries less detail than
|
||||
// the loose one did — the same knob has to be turned further to land where
|
||||
// CLARITY 10 sat before. The gain was picked by clarity-halo.mjs, whose stroke
|
||||
// is the pass pushing a pixel outside its own neighbourhood's range: at +10 this
|
||||
// lands 55/255 (lightroom_shadow.jpg) and 54/255 (DSCF1701.JPG) against the old
|
||||
// pass's 145 and 127 at the same knob, and 8.0 already reads 98 — the knob does
|
||||
// not need to go further to keep the flat areas moving.
|
||||
const float CLARITY_DETAIL_GAIN = 4.5;
|
||||
// The same 1e-4 the doc uses under both luminance terms: an epsilon in the
|
||||
// division that keeps a black pixel's ratio finite without moving any pixel a
|
||||
// full step.
|
||||
const float CLARITY_EPS = 0.0001;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = original.eval(xy);
|
||||
vec3 b = blurred.eval(xy).rgb;
|
||||
vec3 d = c.rgb - b;
|
||||
vec3 out_rgb = strength >= 0.0 ? c.rgb + d * strength : mix(c.rgb, b, clamp(-strength, 0.0, 1.0));
|
||||
return vec4(clamp(out_rgb, 0.0, 1.0), c.a);
|
||||
// Below zero there is no detail to amplify, only the reference to move toward.
|
||||
if (strength < 0.0) {
|
||||
return vec4(clamp(mix(c.rgb, b, clamp(-strength, 0.0, 1.0)), 0.0, 1.0), c.a);
|
||||
}
|
||||
float lo = dot(c.rgb, CLARITY_LUM);
|
||||
float lb = dot(b, CLARITY_LUM);
|
||||
float mid = clamp(4.0 * lb * (1.0 - lb), 0.0, 1.0);
|
||||
float nl = clamp(lb + (lo - lb) * (1.0 + strength * CLARITY_DETAIL_GAIN * mid), 0.0, 1.0);
|
||||
float scale = (nl + CLARITY_EPS) / (lo + CLARITY_EPS);
|
||||
return vec4(clamp(c.rgb * scale, 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -119,6 +119,20 @@ const shots = new Map<string, Promise<Shot | null>>();
|
||||
const CATALOGUE_MS = 5000;
|
||||
const CATCHUP_MS = 30000;
|
||||
|
||||
// The two refusals the write comes back with when the folder is not there or the
|
||||
// browser is not letting this page write to it: the names `libraryBackup` throws
|
||||
// them by, and the ones the picker is the answer to.
|
||||
const needsPick = (err: unknown) =>
|
||||
err instanceof Error && (err.message === 'no-backup-permission' || err.message === 'no-backup-folder');
|
||||
|
||||
// A word for the note's parenthesis: the name the browser gave the refusal, or
|
||||
// the short name the backup threw, and never a stack.
|
||||
const why = (err: unknown) => {
|
||||
if (err instanceof Error && err.message.startsWith('no-backup-')) return err.message.replace('no-backup-', '');
|
||||
if (typeof DOMException !== 'undefined' && err instanceof DOMException) return err.name;
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export function Library() {
|
||||
@@ -274,11 +288,31 @@ export function Library() {
|
||||
setBackBusy(true);
|
||||
if (!quiet) setNote(t('lib.backupRun', { done: 0, total: backRef.current?.photos ?? 0 }));
|
||||
try {
|
||||
const result = await backupNow((p) => setNote(t('lib.backupRun', { done: p.done, total: p.total })));
|
||||
await show(await backupStatus());
|
||||
if (!quiet) setNote(t('lib.backupDone', { n: result.photos, w: result.written }));
|
||||
} catch {
|
||||
if (!quiet) setNote(t('lib.backupFailed'));
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const result = await backupNow((p) => setNote(t('lib.backupRun', { done: p.done, total: p.total })));
|
||||
await show(await backupStatus());
|
||||
if (!quiet) setNote(t('lib.backupDone', { n: result.photos, w: result.written }));
|
||||
break;
|
||||
} catch (err) {
|
||||
// What the row was reading is the permission of a moment ago, and
|
||||
// the browser is free to want the click again by the time the write
|
||||
// reaches the disk — asking for it again from here is a request
|
||||
// without one behind it, and a refusal. The picker is the only
|
||||
// thing that hands a folder back, so a refusal that names the
|
||||
// permission goes through it once; anything else is the folder
|
||||
// itself saying no, and twice is the browser's answer.
|
||||
//
|
||||
// The sentence on the screen cannot carry a reason — it is one line
|
||||
// over a strip of photographs — so the reason goes to the console
|
||||
// and a word of it into the note, which is the whole of what tells
|
||||
// a folder that was moved from a permission that lapsed.
|
||||
console.error('backup:', err);
|
||||
if (attempt === 0 && needsPick(err) && (await pickBackup())) continue;
|
||||
if (!quiet) setNote(t('lib.backupFailed', { why: why(err) }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBackBusy(false);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
CLARITY_SKSL,
|
||||
CLARITY_BLUR_SKSL,
|
||||
CLARITY_BLEND_SKSL,
|
||||
CLARITY_GAIN,
|
||||
DEHAZE_SKSL,
|
||||
dehazeUniformArray,
|
||||
getToneUniforms,
|
||||
@@ -1052,7 +1051,11 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
if (!reference) return null;
|
||||
own(reference);
|
||||
const shader = clarityBlendEffect.makeShaderWithChildren(
|
||||
[(clarityKnob / 10) * (clarityKnob > 0 ? CLARITY_GAIN : 1)],
|
||||
// Amount in the blend's own [-1, 1] units: the positive side's gain
|
||||
// (CLARITY_DETAIL_GAIN) lives INSIDE the shader now, so the mask's
|
||||
// CLARITY_GAIN — a different quantity on a different child — is not
|
||||
// borrowed for this one and the two knobs stay independent.
|
||||
[clarityKnob / 10],
|
||||
[own(imageShaderChild(snap)), own(imageShaderChild(reference))]
|
||||
);
|
||||
return shader ? own(shader) : null;
|
||||
|
||||
@@ -292,7 +292,7 @@ export const en: Dict = {
|
||||
'lib.backupPick': 'Pick the folder the catalogue is written into and read back from — the folder name is the backup name.',
|
||||
'lib.backupRun': 'Backing up {done}/{total}…',
|
||||
'lib.backupDone': 'Backed up {n} frames, {w} tiles written.',
|
||||
'lib.backupFailed': 'The backup folder could not be written to.',
|
||||
'lib.backupFailed': 'The backup folder could not be written to ({why}).',
|
||||
'lib.restoreConfirm': 'Restore {n} frames from {folder}, written {time}? Frames the library already holds are left as they are.',
|
||||
'lib.restoreRun': 'Restoring {done}/{total}…',
|
||||
'lib.restoreDone': 'Restored {n} frames. Add the folder again so every frame gets its file back.',
|
||||
|
||||
@@ -305,7 +305,7 @@ export const vi = {
|
||||
'lib.backupPick': 'Chọn thư mục mà danh mục được ghi vào và đọc lại từ đó — tên thư mục chính là tên bản sao lưu.',
|
||||
'lib.backupRun': 'Đang sao lưu {done}/{total}…',
|
||||
'lib.backupDone': 'Đã sao lưu {n} ảnh, ghi {w} thumbnail.',
|
||||
'lib.backupFailed': 'Không ghi được vào thư mục sao lưu.',
|
||||
'lib.backupFailed': 'Không ghi được vào thư mục sao lưu ({why}).',
|
||||
'lib.restoreConfirm': 'Phục hồi {n} ảnh từ {folder}, ghi lúc {time}? Những ảnh thư viện đã có sẽ được giữ nguyên.',
|
||||
'lib.restoreRun': 'Đang phục hồi {done}/{total}…',
|
||||
'lib.restoreDone': 'Đã phục hồi {n} ảnh. Thêm lại thư mục ảnh để mỗi ảnh lấy lại được tệp của nó.',
|
||||
|
||||
Reference in New Issue
Block a user