images: bust CF cache for the de-watermarked art; add the dewatermark tool
build-and-deploy / build (push) Failing after 10m31s
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:
@@ -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)`);
|
||||||
@@ -17,7 +17,7 @@ const { posts } = Astro.props;
|
|||||||
<a class="post__link" href={`/blog/${post.id}/`}>
|
<a class="post__link" href={`/blog/${post.id}/`}>
|
||||||
{post.data.hero && (
|
{post.data.hero && (
|
||||||
<div class="post__thumb">
|
<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>
|
||||||
)}
|
)}
|
||||||
<div class="post__card-body">
|
<div class="post__card-body">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const dossierNo = String(index).padStart(3, "0");
|
|||||||
{p.cover
|
{p.cover
|
||||||
? <img
|
? <img
|
||||||
class="dossier__cover"
|
class="dossier__cover"
|
||||||
src={`/covers/thumb/${p.cover}.webp`}
|
src={`/covers/thumb/${p.cover}.webp?v=3`}
|
||||||
alt=""
|
alt=""
|
||||||
width="640"
|
width="640"
|
||||||
height="429"
|
height="429"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const { Content } = await render(entry);
|
|||||||
{entry.data.hero && (
|
{entry.data.hero && (
|
||||||
<figure class="post__hero" data-reveal>
|
<figure class="post__hero" data-reveal>
|
||||||
<img
|
<img
|
||||||
src={`${entry.data.hero}?v=2`}
|
src={`${entry.data.hero}?v=3`}
|
||||||
alt={entry.data.heroAlt ?? entry.data.title}
|
alt={entry.data.heroAlt ?? entry.data.title}
|
||||||
width="1600"
|
width="1600"
|
||||||
height="1073"
|
height="1073"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const featuredSeries = series[featured.id];
|
|||||||
{featured.data.hero && (
|
{featured.data.hero && (
|
||||||
<div class="featured__art" aria-hidden="true">
|
<div class="featured__art" aria-hidden="true">
|
||||||
<img
|
<img
|
||||||
src={`${featured.data.hero}?v=2`}
|
src={`${featured.data.hero}?v=3`}
|
||||||
alt=""
|
alt=""
|
||||||
width="1600"
|
width="1600"
|
||||||
height="1073"
|
height="1073"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const { Content } = await render(entry);
|
|||||||
{p.cover && (
|
{p.cover && (
|
||||||
<figure class="case__cover" data-reveal>
|
<figure class="case__cover" data-reveal>
|
||||||
<img
|
<img
|
||||||
src={`/covers/${p.cover}.webp`}
|
src={`/covers/${p.cover}.webp?v=3`}
|
||||||
alt={`${p.title} - cover art`}
|
alt={`${p.title} - cover art`}
|
||||||
width="1600"
|
width="1600"
|
||||||
height="1073"
|
height="1073"
|
||||||
|
|||||||
Reference in New Issue
Block a user