diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js index 39fb3d9..41569e5 100644 --- a/docker/frontend/public/sw.js +++ b/docker/frontend/public/sw.js @@ -10,7 +10,7 @@ // hashed bundle with it, which the STATIC rule below then served cache-first // forever. Every navigation here now goes past the browser's own cache, so the // build a visitor gets is the one the server has. -const VERSION = 'recipescam-v6'; +const VERSION = 'recipescam-v7'; // nginx answers all three with the same index.html (SPA fallback), so they are one // document under three keys: an offline navigation finds it whichever key it asks. diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index fde236b..dc0a3ab 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -103,7 +103,7 @@ for (const k of KNOBS) assert.match(maths, /: -amt \* \(/); // ...pinned at the SOURCE's own numbers: the hinge from knot 0.045 to knot 0.185 // on BLACK's plus side is worth 0.1155 - 0.0876, over a segment 0.140 wide. -assert.match(maths, text(' + (0.0700) * clamp((L - 0.000) / 0.050, 0.0, 1.0)')); +assert.match(maths, text(' + (0.0279) * clamp((L - 0.045) / 0.140, 0.0, 1.0)')); // The composition, move for move: the four in the order the exports were read, // each self-clamping (tone* does it), so a knob that has driven the luma to an // end leaves the ones after it nothing to fold. @@ -311,14 +311,14 @@ function ramp(t, k = {}) { // the tolerance below is 0.01, a little over a code value at ±100. const MEASURED_L = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]; const MEASURED = { - 'hl+': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0667, 0.13, 0.19], - 'hl-': [0.0, 0.0, 0.0, 0.0, 0.0, -0.0333, -0.1, -0.16, -0.22], + 'hl+': [0.0419, 0.1116, 0.1814, 0.2323, 0.1607, 0.1011, 0.078, 0.0452, 0.0108], + 'hl-': [-0.0201, -0.0363, -0.0475, -0.0589, -0.0701, -0.0808, -0.1298, -0.1627, -0.1461], 'sh+': [0.18, 0.215, 0.25, 0.2333, 0.2, 0.12, 0.04, 0.0, 0.0], - 'sh-': [-0.06, -0.12, -0.1533, -0.1867, -0.1967, -0.15, -0.05, 0.0, 0.0], - 'wh+': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.06, 0.18], - 'wh-': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -0.02, -0.04], - 'bl+': [0.15, 0.1143, 0.0769, 0.0385, 0.0, 0.0, 0.0, 0.0, 0.0], - 'bl-': [-0.0975, -0.1733, -0.1967, -0.22, 0.0, 0.0, 0.0, 0.0, 0.0], + 'sh-': [-0.0434, -0.0874, -0.1285, -0.1583, -0.1359, -0.0912, -0.0734, -0.0473, -0.012], + 'wh+': [0.2036, 0.2901, 0.3714, 0.4422, 0.51, 0.5623, 0.4998, 0.3999, 0.3022], + 'wh-': [-0.0019, -0.0038, -0.0058, -0.0085, -0.0123, -0.0203, -0.03, -0.0461, -0.0609], + 'bl+': [0.0905, 0.1049, 0.1132, 0.1151, 0.099, 0.0843, 0.0702, 0.052, 0.0328], + 'bl-': [-0.0501, -0.0997, -0.1498, -0.1983, -0.2429, -0.2247, -0.1574, -0.1141, -0.0773], }; for (const [name, want] of Object.entries(MEASURED)) { const k = name.slice(0, 2); @@ -338,10 +338,10 @@ assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp a // quotes come off before the lookup. const filmFlat = filmTone.replace(/['"]/g, ''); for (const [name, src, kn, toe, head] of [ - ['classic-chrome', 'sh: -0.47', { sh: -0.47 }, 0.1466], - ['classic-vivid', 'sh: -0.47', { sh: -0.47 }, 0.1466], - ['monochrome', 'sh: -0.20, hl: -0.1143', { sh: -0.2, hl: -0.1143 }, 0.206, 0.7277], - ['mono-high-contrast', 'sh: -1.0, wh: 0.26', { sh: -1.0, wh: 0.26 }, 0.03, 0.802], + ['classic-chrome', 'sh: -0.47', { sh: -0.47 }, 0.1791844], + ['classic-vivid', 'sh: -0.47', { sh: -0.47 }, 0.1791844], + ['monochrome', 'sh: -0.20, hl: -0.1143', { sh: -0.2, hl: -0.1143 }, 0.2126235, 0.7396688], + ['mono-high-contrast', 'sh: -1.0, wh: 0.26', { sh: -1.0, wh: 0.26 }, 0.1734727, 0.8150685], ]) { assert.ok( filmFlat.includes(`${name}: { ${src} }`), @@ -391,13 +391,13 @@ for (const k of KNOBS) // ceiling from there up, which is the "+0.43 mean" a user sees as the frame // opening — and BLACKS -100 puts everything below 0.21 on 0.0 (under a code // value), the crushed toe. -for (let L = 0.8; L <= 1.0001; L += 1 / 512) +for (let L = 0.5; L <= 1.0001; L += 1 / 512) assert.equal(Math.round(knob(L, 'wh', 1) * 255), 255, `WHITES +100 leaves luma ${L} short of the ceiling`); for (let L = 0; L <= 0.2; L += 1 / 512) assert.ok(knob(L, 'bl', -1) < 0.006, `BLACKS -100 leaves luma ${L} above the floor`); // On a mid-grey the four are all live — the old windows left the 0.50 midpoint to // nothing but the two ends, which is the "không thay đổi" the report is about. -for (const k of ['sh', 'hl']) { +for (const k of KNOBS) { assert.ok(Math.abs(knob(0.5, k, 1) - 0.5) > 0.01, `${k} +100 does nothing on a mid-grey`); assert.ok(Math.abs(knob(0.5, k, -1) - 0.5) > 0.001, `${k} -100 does nothing on a mid-grey`); } @@ -530,7 +530,7 @@ for (const rgb of [...colourCases, ...greyCases]) { // both, which is a colour going grey under a lift. for (const [rgb, knobs] of [ [[0.7, 0.55, 0.45], { hl: 0.5 }], - [[0.35, 0.12, 0.08], { sh: 1, bl: 1 }], + [[0.25, 0.12, 0.08], { sh: 0.5, bl: 0.5 }], ]) { const lifted = rebuild(rgb, knobs); const grew = (lifted.clamped[0] - lifted.clamped[1]) / (rgb[0] - rgb[1]); diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 63fbcf9..9dd3ec7 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -182,28 +182,31 @@ export interface ToneKnobCurve { v: number[]; // the move at +100, in luma, at each knot } export const TONE_KNOBS: Record = { - // BLACKS (bl): active range [0.0, 0.25] + // BLACKS (bl): measured Lightroom Classic 14.2 curve. + // + lifts from the very floor and peaks at 0.115 by luma 0.19. + // - clips everything below 0.23 to 0.0. bl: { - plus: { p: [0, 0.05, 0.12, 0.25, 1], v: [0.08, 0.15, 0.1, 0, 0] }, - minus: { p: [0, 0.08, 0.2, 0.3, 1], v: [-0.01, -0.15, -0.22, 0, 0] }, + plus: { p: [0, 0.01, 0.025, 0.045, 0.185, 0.645, 0.71, 0.73, 1], v: [0.0042, 0.0131, 0.0649, 0.0876, 0.1155, 0.0442, 0.0301, 0.0138, 0] }, + minus: { p: [0, 0.23, 0.34, 0.37, 0.51, 0.73, 1], v: [-0.0017, -0.2245, -0.253, -0.2502, -0.1518, -0.0707, 0] }, }, - // SHADOWS (sh): active range [0.0, 0.55] - // Lifts dark shadows (L=0.03..0.40) up to +0.25 to reveal shadow detail, zero for L >= 0.55 + // SHADOWS (sh): lifts dark shadows (L=0.03..0.40) up to +0.25 to reveal shadow detail, zero for L >= 0.55 sh: { plus: { p: [0, 0.05, 0.15, 0.3, 0.45, 0.55, 1], v: [0.02, 0.18, 0.25, 0.2, 0.08, 0, 0] }, - minus: { p: [0, 0.1, 0.25, 0.4, 0.55, 1], v: [0, -0.12, -0.22, -0.15, 0, 0] }, + minus: { p: [0, 0.155, 0.22, 0.29, 0.355, 0.395, 0.46, 0.73, 1], v: [-0.0017, -0.1324, -0.1613, -0.1365, -0.1313, -0.0926, -0.0879, 0, 0] }, }, - // HIGHLIGHTS (hl): active range [0.40, 1.0] - // Zero for L <= 0.40, lifting upper midtones & highlights (L=0.55..0.90) without touching dark silhouettes + // HIGHLIGHTS (hl): measured Lightroom Classic 14.2 curve. + // plus: 0 for L <= 0.02 (prevents lifting pure black silhouettes), +0.237 at L=0.19, tapering to 0 at 0.73. + // minus: -0.169 recovery at L=0.625. hl: { - plus: { p: [0, 0.4, 0.55, 0.75, 0.9, 1], v: [0, 0, 0.1, 0.22, 0.12, 0] }, - minus: { p: [0, 0.35, 0.5, 0.7, 0.9, 1], v: [0, 0, -0.1, -0.22, -0.12, 0] }, + plus: { p: [0, 0.02, 0.19, 0.29, 0.355, 0.405, 0.485, 0.73, 1], v: [0, 0, 0.2372, 0.1632, 0.1424, 0.0987, 0.0828, 0.0001, 0] }, + minus: { p: [0, 0.22, 0.4, 0.5, 0.625, 0.695, 0.73, 1], v: [-0.0002, -0.0634, -0.0808, -0.1298, -0.1689, -0.1505, -0.0978, 0] }, }, - // WHITES (wh): active range [0.55, 1.0] - // Zero for L <= 0.55, driving bright highlights up towards white + // WHITES (wh): measured Lightroom Classic 14.2 curve. + // plus: drives everything above luma 0.50 to 1.0 (clipping point towards white). + // minus: pulls white point down by -0.10 on 1.0. wh: { - plus: { p: [0, 0.55, 0.7, 0.85, 1], v: [0, 0, 0.18, 0.24, 0] }, - minus: { p: [0, 0.5, 0.7, 0.9, 1], v: [0, 0, -0.04, -0.08, -0.1] }, + plus: { p: [0, 0.01, 0.025, 0.035, 0.05, 0.175, 0.22, 0.35, 0.385, 0.465, 0.73, 1], v: [0.0023, 0.0169, 0.1217, 0.1634, 0.2036, 0.4103, 0.464, 0.5295, 0.5647, 0.534, 0.27, 0] }, + minus: { p: [0, 0.495, 0.7, 0.73, 1], v: [0, -0.0294, -0.0609, -0.0831, -0.1] }, }, }; diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index a8ae650..1d84e58 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -7e7607f47321f95e192d67313520f929ff7fe29b78ba865d5eb3a1f1e519ae15 recipescam-images.tar.gz +3682224e6a070335faf08e7d2500d32815c809d9dfc4762eb563c591e2c63863 recipescam-images.tar.gz diff --git a/docker/recipescam-web-frontend-acbb2bb-tone.tar.gz.sha256 b/docker/recipescam-web-frontend-acbb2bb-tone.tar.gz.sha256 index 76ac569..c3e3053 100644 --- a/docker/recipescam-web-frontend-acbb2bb-tone.tar.gz.sha256 +++ b/docker/recipescam-web-frontend-acbb2bb-tone.tar.gz.sha256 @@ -1 +1 @@ -fc1fa727ca4400483b16124eaf8af69df4e4caa25f7acee89c8cdb6548e00833 recipescam-web-frontend-acbb2bb-tone.tar.gz +e9384d969749fcfdf5e0d619657a1090ef10609ea57aaeaf9e30f7fab3ee1e6d recipescam-web-frontend-acbb2bb-tone.tar.gz diff --git a/docker/recipescam-web-frontend-acbb2bb.tar.gz.sha256 b/docker/recipescam-web-frontend-acbb2bb.tar.gz.sha256 index ae51647..99f74fc 100644 --- a/docker/recipescam-web-frontend-acbb2bb.tar.gz.sha256 +++ b/docker/recipescam-web-frontend-acbb2bb.tar.gz.sha256 @@ -1 +1 @@ -00b6f31763459815bd62d80734287c1eb424d6bf552ed895e8a2e935f8e7e437 recipescam-web-frontend-acbb2bb.tar.gz +9ca6616f05d88bb44064cecc27453d4b7204f31c63decd795ed3b311a3389586 recipescam-web-frontend-acbb2bb.tar.gz