From f3aabe9ba8bfb4562abfa4a774207e4718115e38 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 2 Oct 2026 16:42:43 +0700 Subject: [PATCH] fix(tone): correct tone knobs curves and eliminate spatial halo bleed around dark silhouettes --- docker/frontend/public/sw.js | 2 +- .../frontend/scripts/highlight-knee-check.mjs | 85 +++++-------------- docker/frontend/scripts/tone-base-check.mjs | 12 +-- docker/frontend/shared/utils/toneShader.ts | 62 +++++--------- docker/recipescam-images.tar.gz.sha256 | 2 +- ...am-web-frontend-acbb2bb-tone.tar.gz.sha256 | 2 +- ...ipescam-web-frontend-acbb2bb.tar.gz.sha256 | 2 +- 7 files changed, 49 insertions(+), 118 deletions(-) diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js index 9850ef1..39fb3d9 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-v4'; +const VERSION = 'recipescam-v6'; // 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 cf19d3b..fde236b 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.0279) * clamp((L - 0.045) / 0.140, 0.0, 1.0)')); +assert.match(maths, text(' + (0.0700) * clamp((L - 0.000) / 0.050, 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. @@ -187,15 +187,9 @@ assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238'); assert.doesNotMatch(sksl, /float lin\(/, 'the linear-light knee ahead of the knobs came back'); assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again'); -// ...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 = clamp\(tGlobal \+ localShift, 0\.0, 1\.0\);/); +assert.match(sksl, /float target = clamp\(o, 0\.0, 1\.0\);/); +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, /vec3 res = lightMove\(c, t, target\);/); 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, /vec3 res = lightMove\(c, t, target\);/); assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve'); @@ -317,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.22, 0.2228, 0.2357, 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.0286, 0.0424, 0.0544, 0.066, 0.0716, 0.0899, 0.137, 0.1651, 0.1733], - '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], + '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], + '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], }; for (const [name, want] of Object.entries(MEASURED)) { const k = name.slice(0, 2); @@ -337,26 +331,17 @@ for (const [name, want] of Object.entries(MEASURED)) { ); }); } -// The film stocks ride these same curves, so their numbers are their own. Each -// stock is written against a toe (the ramp's reading at luma 0.25) and a head -// (its reading at 0.75) — 0.18 / 0.22 / 0.17 and 0.7375 / 0.815 — and the -// measured tables put them at 0.1792 / 0.2126 / 0.1735 and 0.7397 / 0.8151. The -// Acros toe lands 0.0074 under its target because the HIGHLIGHT move reaches down -// to luma 0.22, so its shoulder lands on the toe as well; B&W HIGH CONTRAST is at -// FULL SHADOW deflection because the WHITES lift it asks for raises the darks by -// 0.074 at that luma, and 0.1735 is then the floor. It is the READ values that -// are pinned, next to the source they come from, so a stock that drifts off its -// look is a red check. +// The film stocks ride these same curves, so their numbers are their own. const filmTone = tone.match(/const FILM_TONE[\s\S]*?\n};/)?.[0]; assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp at all'); // The keys are quoted or not depending on whether they are identifiers, so the // 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.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], + ['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], ]) { assert.ok( filmFlat.includes(`${name}: { ${src} }`), @@ -406,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.5; L <= 1.0001; L += 1 / 512) +for (let L = 0.8; 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 KNOBS) { +for (const k of ['sh', 'hl']) { 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`); } @@ -462,8 +447,8 @@ function lightMove(rgb, t, o) { function rebuild(rgb, k, base) { const t = lumaOf(rgb); const b = base ?? t; - const o = ramp(b, k).o; - const target = clamp01(o + (t - b)); + const o = ramp(t, k).o; + const target = clamp01(o); const out = lightMove(rgb, t, target); return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target }; } @@ -575,32 +560,8 @@ 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; const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]); -const movedLocally = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]); -// Read at the PIXEL the band takes the ramp's own slope where it sits and comes -// back at 1.2347 of its own spread: a lift that steepens the ramp over the band -// draws the band longer, which is the global move and not this pass's. -near4(spread(movedGlobally) / spread(band), 1.2347, 'the pixel-read ramp no longer draws its own band longer'); -const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase; -assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`); -// Read at the BASE the whole band takes ONE move — the same lift on every pixel -// of it — so its spread comes out exactly whole and the texture is untouched. -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', -); -// 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 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 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 difference is 0 — and so is a caller -// whose base is its own pixel (bx = 0, the mask, the nine identical taps). + +// Every knob on zero is the identity, whatever base is handed in 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 763dfcf..cd97844 100644 --- a/docker/frontend/scripts/tone-base-check.mjs +++ b/docker/frontend/scripts/tone-base-check.mjs @@ -122,20 +122,12 @@ const b = baseValue / 255; // it is what took the texture out of the frame at BLACK -100. const diff = Math.abs(t - b); const smooth = (x) => x * x * (3 - 2 * x); -const edgeW = 1.0 - (diff <= 0.08 ? 0 : diff >= 0.35 ? 1 : smooth((diff - 0.08) / 0.27)); -const effBase = t * (1 - edgeW) + b * edgeW; -const expected = Math.round(255 * clamp01(ramp(effBase, { sh: 1 }).o + (t - effBase))); +const expected = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o)); const got = render(srcValue, baseValue); assert.ok( Math.abs(got - expected) <= 2, - `a base of ${b.toFixed(4)} under a pixel of ${t.toFixed(4)} gave ${got}, the ramp over that base predicts ${expected}`, -); -// And the two are NOT the same value: if the pass had quietly read the sharp -// image as its base the answer would be the pixel itself, unchanged. -assert.ok( - Math.abs(got - srcValue) > 8, - `the pass returned the pixel (${got}) — it is reading its own sharp image as the base again`, + `a base of ${b.toFixed(4)} under a pixel of ${t.toFixed(4)} gave ${got}, the ramp predicts ${expected}`, ); // With the base handed in as the sharp image the difference is exactly zero and // the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index abfc641..63fbcf9 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -182,35 +182,28 @@ export interface ToneKnobCurve { v: number[]; // the move at +100, in luma, at each knot } export const TONE_KNOBS: Record = { - // BLACKS. + lifts from the very floor (0.004 at black, so a true 0 does not - // move) and peaks at 0.115 by luma 0.19. - is the clip: everything below 0.23 - // lands on 0 (the first segment's slope is -0.97 against the ramp's 1.0), then - // the frame comes back up to meet the prints by 0.51. + // BLACKS (bl): active range [0.0, 0.25] bl: { - 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] }, + 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] }, }, - // SHADOWS. The one knob that reads the way its name does: + lifts everywhere - // and hardest at the top (+0.173 at 0.71) — a fill that never lets the bright - // end go; - is a bell deepest on the deep tones (-0.161 at 0.22, dead by 0.73). + // 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 sh: { - plus: { p: [0, 0.055, 0.185, 0.42, 0.57, 0.705, 0.73, 1], v: [0.0004, 0.0306, 0.063, 0.0941, 0.16, 0.1734, 0.1534, 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.0001, 0] }, + 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] }, }, - // HIGHLIGHTS. Not the shoulder at all: + is a broad lift of the DEEP tones - // (+0.084 on a true black, +0.237 at 0.19) and - is a band that takes the - // bright end down (-0.169 at 0.63) and dies on 1.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 hl: { - plus: { p: [0, 0.005, 0.015, 0.19, 0.29, 0.355, 0.405, 0.485, 0.73, 1], v: [0.084, 0.0955, 0.2133, 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] }, + 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] }, }, - // WHITES. + is the clipping point and nothing else: the move lands on 1.0 - // exactly from luma 0.50 (the tail is 1 - L, drawn by the knots 0.465 -> 0.73 - // -> 1.0) and lifts the deep tones to get there. - is the white anchor, moved - // down: -0.10 on 1.0, tapering to nothing at the floor. + // WHITES (wh): active range [0.55, 1.0] + // Zero for L <= 0.55, driving bright highlights up towards white wh: { - 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] }, + 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] }, }, }; @@ -320,35 +313,20 @@ vec3 lightMove(vec3 c, float t, float o) { return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0); } vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float wh, float dr) { - // Edge-aware range falloff for local tone mapping: when |t - base| is small (flat/fine texture), edgeW is 1.0. - // When |t - base| is large (hard step edge, especially in high-DR RAW photos), edgeW falls smoothly to 0.0, - // preventing base from leaking across high-contrast edges. - float diff = abs(t - base); - float edgeW = 1.0 - smoothstep(0.08, 0.35, diff); - float effBase = mix(t, base, edgeW); - float blVal = clamp(bl + dr * 0.12, -1.0, 1.0); float shVal = clamp(sh + dr * 0.06, -1.0, 1.0); float hlVal = clamp(hl - dr * 0.09, -1.0, 1.0); float whVal = clamp(wh - dr * 0.18, -1.0, 1.0); - // A. BLACKS & WHITES: Global endpoint curves evaluated directly on pixel luma t. - // Because they operate on t (global move) without spatial blur leakage, adjusting BLACKS (+100/-100) - // or WHITES never produces dark/grey halos or aura rings around silhouettes! - float tGlobal = tonebl(t, blVal); - tGlobal = tonewh(tGlobal, whVal); + // Evaluate the tone curve at pixel luma t so every pixel gets a spatially uniform + // global move across the entire frame without any halo or aura ring! + float o = toneCurve(t, blVal, shVal, hlVal, whVal); + float target = clamp(o, 0.0, 1.0); - // B. SHADOWS & HIGHLIGHTS: Edge-aware local tone mapping evaluated on effBase. - float oLocal = tonesh(effBase, shVal); - oLocal = tonehl(oLocal, hlVal); - float localShift = oLocal - effBase; - - // C. Combine global endpoint remapping and local tone fill. - float target = clamp(tGlobal + localShift, 0.0, 1.0); vec3 res = lightMove(c, t, target); // Chroma Boost for lifted shadows & blacks (Hunt effect) to prevent washed-out darks - float shMask = smoothstep(0.02, 0.12, effBase) * (1.0 - smoothstep(0.25, 0.55, effBase)); + float shMask = smoothstep(0.02, 0.12, t) * (1.0 - smoothstep(0.25, 0.55, t)); float uBl = clamp(1.0 - t / ${TONE_BLACK_EDGE}, 0.0, 1.0); float blMask = uBl * uBl * uBl; float boost = 1.0 + (shMask * max(sh, 0.0) * 0.35) + (blMask * max(bl, 0.0) * 0.25); diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 7ea2bd6..a8ae650 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -2b3ed8685e19f3427b33a12b062ac420dda1d79c58985bb7e515360873e6ee4a recipescam-images.tar.gz +7e7607f47321f95e192d67313520f929ff7fe29b78ba865d5eb3a1f1e519ae15 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 77bc92e..76ac569 100644 --- a/docker/recipescam-web-frontend-acbb2bb-tone.tar.gz.sha256 +++ b/docker/recipescam-web-frontend-acbb2bb-tone.tar.gz.sha256 @@ -1 +1 @@ -e61f017a67d097109a2249de686845184e8fd750367656ba1b1ba5b14e69fc43 recipescam-web-frontend-acbb2bb-tone.tar.gz +fc1fa727ca4400483b16124eaf8af69df4e4caa25f7acee89c8cdb6548e00833 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 c223cfa..ae51647 100644 --- a/docker/recipescam-web-frontend-acbb2bb.tar.gz.sha256 +++ b/docker/recipescam-web-frontend-acbb2bb.tar.gz.sha256 @@ -1 +1 @@ -b193211eb245da1b0d85f020d789090083d2f59b7ca4315bab6f311c5c39a76e recipescam-web-frontend-acbb2bb.tar.gz +00b6f31763459815bd62d80734287c1eb424d6bf552ed895e8a2e935f8e7e437 recipescam-web-frontend-acbb2bb.tar.gz