From cf900b340919fb6f438436bb47d827d6a2d0f927 Mon Sep 17 00:00:00 2001 From: Jonathon Wright Date: Tue, 25 Aug 2026 18:20:29 +1000 Subject: [PATCH] images: bust CF cache for the de-watermarked art; add the dewatermark tool public/ assets are edge-cached under non-hashed URLs, so the scrubbed covers and heroes were still being served stale. Project covers carried no cache-bust at all - added ?v=3 to both cover references and bumped the blog heroes from v2 to v3. Adds scripts/dewatermark.mjs implementing the documented approach (hard-coded mark centre, feathered disc, border-mean seed, Jacobi diffusion) rather than the ffmpeg delogo used in the previous pass. --- scripts/dewatermark.mjs | 92 ++++++++++++++++++++++++++++++++ src/components/PostList.astro | 2 +- src/components/ProjectCard.astro | 2 +- src/pages/blog/[slug].astro | 2 +- src/pages/blog/index.astro | 2 +- src/pages/projects/[slug].astro | 2 +- 6 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 scripts/dewatermark.mjs diff --git a/scripts/dewatermark.mjs b/scripts/dewatermark.mjs new file mode 100644 index 0000000..b98e5f4 --- /dev/null +++ b/scripts/dewatermark.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Strip a corner watermark (the generator's four-point sparkle) from a cover/hero. +// Method per the astro-static-site skill: feathered disc mask at a HARD-CODED centre, +// seed with the border mean, then Jacobi-diffuse so the patch blends into the surround. +// Deliberately NOT brightness-auto-detected - that locks onto neon edges and highlights. +// +// node scripts/dewatermark.mjs [cx] [cy] [r] [iters] +// +// Default centre is the usual mark position on a 1600x1074 render. +import sharp from "sharp"; + +const [, , inPath, outPath, cxA, cyA, rA, itA] = process.argv; +if (!inPath || !outPath) { + console.error("usage: dewatermark.mjs [cx] [cy] [r] [iters]"); + process.exit(1); +} +const R = Number(rA ?? 46); +const ITERS = Number(itA ?? 900); + +const img = sharp(inPath); +const { width: W, height: H } = await img.metadata(); +const CX = Number(cxA ?? 1451); +const CY = Number(cyA ?? 922); + +const { data, info } = await img.raw().toBuffer({ resolveWithObject: true }); +const CH = info.channels; + +// feathered disc: 1 inside, 0 outside, smooth over the last 6px so the seam disappears +const mask = new Float32Array(W * H); +const x0 = Math.max(0, CX - R - 4), x1 = Math.min(W - 1, CX + R + 4); +const y0 = Math.max(0, CY - R - 4), y1 = Math.min(H - 1, CY + R + 4); +for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + const d = Math.hypot(x - CX, y - CY); + mask[y * W + x] = d <= R - 6 ? 1 : d >= R ? 0 : (R - d) / 6; + } +} + +// seed every masked pixel with the mean colour of the ring just outside the mask +const ring = []; +for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + const d = Math.hypot(x - CX, y - CY); + if (d > R && d <= R + 4) ring.push((y * W + x) * CH); + } +} +const seed = [0, 0, 0]; +for (const o of ring) for (let c = 0; c < 3; c++) seed[c] += data[o + c]; +for (let c = 0; c < 3; c++) seed[c] = ring.length ? seed[c] / ring.length : 0; + +const work = new Float32Array(W * H * 3); +for (let i = 0; i < W * H; i++) { + for (let c = 0; c < 3; c++) { + work[i * 3 + c] = mask[i] > 0 ? seed[c] : data[i * CH + c]; + } +} + +// Jacobi diffusion: each masked pixel relaxes toward its 4-neighbour average, +// known pixels stay pinned, so the fill inherits the surrounding gradient. +const next = new Float32Array(work); +for (let it = 0; it < ITERS; it++) { + for (let y = Math.max(1, y0); y <= Math.min(H - 2, y1); y++) { + for (let x = Math.max(1, x0); x <= Math.min(W - 2, x1); x++) { + const i = y * W + x; + if (mask[i] <= 0) continue; + for (let c = 0; c < 3; c++) { + next[i * 3 + c] = + (work[(i - 1) * 3 + c] + work[(i + 1) * 3 + c] + + work[(i - W) * 3 + c] + work[(i + W) * 3 + c]) / 4; + } + } + } + work.set(next); +} + +// composite back through the feather so the edge of the patch is invisible +const out = Buffer.from(data); +for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + const i = y * W + x, m = mask[i]; + if (m <= 0) continue; + for (let c = 0; c < 3; c++) { + const o = i * CH + c; + out[o] = Math.round(data[o] * (1 - m) + work[i * 3 + c] * m); + } + } +} + +await sharp(out, { raw: { width: W, height: H, channels: CH } }) + .webp({ quality: 85 }) + .toFile(outPath); +console.log(`dewatermarked ${inPath} -> ${outPath} (centre ${CX},${CY} r=${R}, ${ITERS} iters)`); diff --git a/src/components/PostList.astro b/src/components/PostList.astro index 087e771..4f2bfa3 100644 --- a/src/components/PostList.astro +++ b/src/components/PostList.astro @@ -17,7 +17,7 @@ const { posts } = Astro.props; {post.data.hero && (
- +
)}
diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro index 1047373..26bd0e0 100644 --- a/src/components/ProjectCard.astro +++ b/src/components/ProjectCard.astro @@ -20,7 +20,7 @@ const dossierNo = String(index).padStart(3, "0"); {p.cover ?