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