images: bust CF cache for the de-watermarked art; add the dewatermark tool
build-and-deploy / build (push) Failing after 10m31s

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.
This commit is contained in:
2026-08-25 18:20:29 +10:00
parent 4a8552c408
commit cf900b3409
6 changed files with 97 additions and 5 deletions
+92
View File
@@ -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 <in> <out> [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 <in> <out> [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)`);
+1 -1
View File
@@ -17,7 +17,7 @@ const { posts } = Astro.props;
<a class="post__link" href={`/blog/${post.id}/`}>
{post.data.hero && (
<div class="post__thumb">
<img src={`${post.data.hero}?v=2`} alt="" width="1600" height="1073" loading="lazy" decoding="async" />
<img src={`${post.data.hero}?v=3`} alt="" width="1600" height="1073" loading="lazy" decoding="async" />
</div>
)}
<div class="post__card-body">
+1 -1
View File
@@ -20,7 +20,7 @@ const dossierNo = String(index).padStart(3, "0");
{p.cover
? <img
class="dossier__cover"
src={`/covers/thumb/${p.cover}.webp`}
src={`/covers/thumb/${p.cover}.webp?v=3`}
alt=""
width="640"
height="429"
+1 -1
View File
@@ -43,7 +43,7 @@ const { Content } = await render(entry);
{entry.data.hero && (
<figure class="post__hero" data-reveal>
<img
src={`${entry.data.hero}?v=2`}
src={`${entry.data.hero}?v=3`}
alt={entry.data.heroAlt ?? entry.data.title}
width="1600"
height="1073"
+1 -1
View File
@@ -56,7 +56,7 @@ const featuredSeries = series[featured.id];
{featured.data.hero && (
<div class="featured__art" aria-hidden="true">
<img
src={`${featured.data.hero}?v=2`}
src={`${featured.data.hero}?v=3`}
alt=""
width="1600"
height="1073"
+1 -1
View File
@@ -22,7 +22,7 @@ const { Content } = await render(entry);
{p.cover && (
<figure class="case__cover" data-reveal>
<img
src={`/covers/${p.cover}.webp`}
src={`/covers/${p.cover}.webp?v=3`}
alt={`${p.title} - cover art`}
width="1600"
height="1073"