Files
dukang_official/scripts/optimize-stage-images.mjs
T
2026-09-07 00:07:40 +08:00

205 lines
6.7 KiB
JavaScript

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)
})