import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import sharp from 'sharp' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const outThumb = path.join(root, 'public/assets/stage/thumb') const outFull = path.join(root, 'public/assets/stage/full') /** Stage UI dark bg — fringe target */ const DARK = { r: 14, g: 12, b: 16 } /** @type {{ src: string; name: string; thumbW: number; fullW: number }[]} */ const STAGE_SOURCES = [ { src: 'public/assets/logo.png', name: 'logo', thumbW: 48, fullW: 512 }, { src: 'public/assets/qr-contact.png', name: 'qr-contact', thumbW: 48, fullW: 512 }, { src: 'public/assets/t30-bottle.png', name: 't30-bottle', thumbW: 64, fullW: 800 }, { src: 'public/assets/brand/emb_p6_5.png', name: 'emb_p6_5', thumbW: 64, fullW: 960 }, { src: 'public/assets/brand/emb_p33_1.png', name: 'emb_p33_1', thumbW: 64, fullW: 960 }, ] async function ensureDirs() { await fs.mkdir(outThumb, { recursive: true }) await fs.mkdir(outFull, { recursive: true }) } function lum(r, g, b) { return 0.299 * r + 0.587 * g + 0.114 * b } function sat(r, g, b) { return Math.max(r, g, b) - Math.min(r, g, b) } /** * White fringe → dark fringe for dark UI: * 1) transparent RGB → black * 2) semi-transparent: un-white-matte + premultiply toward black * 3) opaque edge near transparency: crush whitish fringe to dark */ async function defringeBottle(srcPathOrBuf) { const { data, info } = await sharp(srcPathOrBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) const w = info.width const h = info.height const out = Buffer.from(data) const idx = (x, y) => (y * w + x) * 4 // Flood-fill only transparent pixels connected to the canvas edge. // This prevents transparent details inside labels/text from being treated as silhouette. const exterior = new Uint8Array(w * h) const queue = new Int32Array(w * h) let head = 0 let tail = 0 const enqueue = (x, y) => { const p = y * w + x if (exterior[p] || data[p * 4 + 3] > 16) return exterior[p] = 1 queue[tail++] = p } for (let x = 0; x < w; x++) { enqueue(x, 0) enqueue(x, h - 1) } for (let y = 0; y < h; y++) { enqueue(0, y) enqueue(w - 1, y) } while (head < tail) { const p = queue[head++] const x = p % w const y = Math.floor(p / w) if (x > 0) enqueue(x - 1, y) if (x + 1 < w) enqueue(x + 1, y) if (y > 0) enqueue(x, y - 1) if (y + 1 < h) enqueue(x, y + 1) } const R = 3 for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const p = y * w + x const i = p * 4 if (exterior[p]) { out[i] = out[i + 1] = out[i + 2] = 0 continue } let distance = R + 1 for (let dy = -R; dy <= R; dy++) { for (let dx = -R; dx <= R; dx++) { const xx = x + dx const yy = y + dy if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue if (exterior[yy * w + xx]) { distance = Math.min(distance, Math.max(Math.abs(dx), Math.abs(dy))) } } } if (distance > R) continue const a = data[i + 3] let r = data[i] let g = data[i + 1] let b = data[i + 2] if (a < 255) { const af = a / 255 r = Math.min(255, Math.max(0, Math.round((r - 255 * (1 - af)) / af))) g = Math.min(255, Math.max(0, Math.round((g - 255 * (1 - af)) / af))) b = Math.min(255, Math.max(0, Math.round((b - 255 * (1 - af)) / af))) } // Turn the complete exterior silhouette into a dark feather: // strongest on the outermost pixel, fading by the third pixel. // Because the mask is flood-filled from the canvas edge, internal labels stay untouched. const strength = distance === 1 ? 0.96 : distance === 2 ? 0.68 : 0.34 out[i] = Math.round(r * (1 - strength) + DARK.r * strength) out[i + 1] = Math.round(g * (1 - strength) + DARK.g * strength) out[i + 2] = Math.round(b * (1 - strength) + DARK.b * strength) } } return sharp(out, { raw: { width: w, height: h, channels: 4 } }).png().toBuffer() } /** After WebP encode, force transparent RGB to black again. */ async function neutralizeTransparentRgb(webpBuf) { const { data, info } = await sharp(webpBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) for (let i = 0; i < data.length; i += 4) { if (data[i + 3] === 0) { data[i] = data[i + 1] = data[i + 2] = 0 } } return sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } }) .webp({ quality: 86, alphaQuality: 100 }) .toBuffer() } async function writeThumb(srcPath, name, width) { const dest = path.join(outThumb, `${name}.webp`) let pipe = sharp(srcPath).resize(width, null, { withoutEnlargement: true }).blur(2) let buf = await pipe.webp({ quality: 50, alphaQuality: 100 }).toBuffer() if (name === 't30-bottle') buf = await neutralizeTransparentRgb(buf) await fs.writeFile(dest, buf) const stat = await fs.stat(dest) return { dest, kb: Math.round((stat.size / 1024) * 10) / 10 } } async function writeFull(srcPath, name, width) { const dest = path.join(outFull, `${name}.webp`) let pipe = sharp(srcPath).resize(width, null, { withoutEnlargement: true, }) let buf = await pipe.webp({ quality: 86, alphaQuality: 100 }).toBuffer() if (name === 't30-bottle') buf = await neutralizeTransparentRgb(buf) await fs.writeFile(dest, buf) const stat = await fs.stat(dest) return { dest, kb: Math.round((stat.size / 1024) * 10) / 10 } } async function rewriteBottlePng(srcPath) { const fixed = await defringeBottle(srcPath) await fs.writeFile(srcPath, fixed) const stat = await fs.stat(srcPath) return Math.round((stat.size / 1024) * 10) / 10 } async function main() { await ensureDirs() const bottleSrc = path.join(root, 'public/assets/t30-bottle.png') const pngKb = await rewriteBottlePng(bottleSrc) console.log(`t30-bottle.png rewritten (${pngKb} KB)`) let thumbTotal = 0 let fullTotal = 0 for (const item of STAGE_SOURCES) { const srcPath = path.join(root, item.src) try { await fs.access(srcPath) } catch { console.warn(`skip (missing): ${item.src}`) continue } // Bottle PNG was already defringed once above; do not process it twice. const thumb = await writeThumb(srcPath, item.name, item.thumbW) const full = await writeFull(srcPath, item.name, item.fullW) thumbTotal += thumb.kb fullTotal += full.kb console.log(`${item.name}: thumb ${thumb.kb} KB, full ${full.kb} KB`) } console.log(`\nDone. thumb total ~${Math.round(thumbTotal)} KB, full total ~${Math.round(fullTotal)} KB`) } main().catch((err) => { console.error(err) process.exit(1) })