diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index 6d2260a..977a6fc 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -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); diff --git a/docker/frontend/scripts/tone-base-check.mjs b/docker/frontend/scripts/tone-base-check.mjs index b17f9b0..f79c63a 100644 --- a/docker/frontend/scripts/tone-base-check.mjs +++ b/docker/frontend/scripts/tone-base-check.mjs @@ -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( diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 6530cce..576481f 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -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); } `; diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 4337e9d..42fd2ec 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -119,6 +119,20 @@ const shots = new Map>(); 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); } diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 7686431..364e9e3 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -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 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; diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 9e88027..2587522 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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.', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index e60bdce..8ff10fa 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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ó.',