Files
bztmon-site/scripts/dewatermark.mjs
T
jwright cf900b3409
build-and-deploy / build (push) Failing after 10m31s
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.
2026-08-25 18:20:29 +10:00

93 lines
3.3 KiB
JavaScript

#!/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)`);