/** * 静态资源压缩 · 把 public/portraits & public/videos 转成桶友好的体积 * * 输入: /public/portraits/*.png, public/videos/{hero-pv.mp4, artists/*.mp4} * 输出: /../assets-compressed/{portraits,videos}/... * * 处理: * - 立绘 / 氛围图 (.png) → .webp, 最大宽 1600, quality 82, 目标 ≤ 800KB * - 三视图 (-view.png) → .webp, 最大宽 2400, quality 82, 目标 ≤ 1.5MB * - 视频 (.mp4) → libx264 CRF 28, 最大宽 1920, AAC 96k, faststart * * 已存在且 mtime 新于源文件的输出会跳过 (可反复运行恢复中断的批处理) */ import sharp from "sharp"; import pLimit from "p-limit"; import { spawn } from "node:child_process"; import { mkdir, readdir, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import { dirname, join, resolve, basename } from "node:path"; import { fileURLToPath } from "node:url"; import ffmpegInstaller from "@ffmpeg-installer/ffmpeg"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, "..", ".."); const OUT_ROOT = resolve(REPO_ROOT, "..", "assets-compressed"); const FFMPEG_BIN = ffmpegInstaller.path; const PORTRAITS_SRC = join(REPO_ROOT, "public", "portraits"); const VIDEOS_SRC = join(REPO_ROOT, "public", "videos"); const PORTRAITS_OUT = join(OUT_ROOT, "portraits"); const VIDEOS_OUT = join(OUT_ROOT, "videos"); const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a); async function fileSize(p) { try { return (await stat(p)).size; } catch { return 0; } } function fmt(n) { if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}MB`; if (n >= 1024) return `${(n / 1024).toFixed(0)}KB`; return `${n}B`; } async function isStale(src, out) { if (!existsSync(out)) return true; const [s, o] = await Promise.all([stat(src), stat(out)]); return o.mtimeMs < s.mtimeMs; } async function compressImage(src, out, maxWidth, quality) { await mkdir(dirname(out), { recursive: true }); await sharp(src) .rotate() .resize({ width: maxWidth, withoutEnlargement: true }) .webp({ quality, effort: 6 }) .toFile(out); } function runFfmpeg(args) { return new Promise((resolveP, rejectP) => { const p = spawn(FFMPEG_BIN, args, { stdio: ["ignore", "ignore", "pipe"] }); let err = ""; p.stderr.on("data", (c) => (err += c.toString())); p.on("close", (code) => { if (code === 0) resolveP(); else rejectP(new Error(`ffmpeg exit ${code}: ${err.slice(-500)}`)); }); }); } async function compressVideo(src, out, { crf = 28, maxWidth = 1920 } = {}) { await mkdir(dirname(out), { recursive: true }); const args = [ "-y", "-loglevel", "error", "-i", src, "-c:v", "libx264", "-preset", "medium", "-crf", String(crf), "-vf", `scale='min(${maxWidth},iw)':-2`, "-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", "-pix_fmt", "yuv420p", out, ]; await runFfmpeg(args); } async function processPortraits() { if (!existsSync(PORTRAITS_SRC)) { log("⚠️ portraits 源目录不存在, 跳过:", PORTRAITS_SRC); return { count: 0, saved: 0 }; } const files = (await readdir(PORTRAITS_SRC)).filter((f) => /\.png$/i.test(f), ); log(`portraits: 找到 ${files.length} 张待处理`); const limit = pLimit(4); // 4 并发即可,sharp 本身有线程 let done = 0; let totalSrc = 0; let totalOut = 0; let skipped = 0; await Promise.all( files.map((name) => limit(async () => { const src = join(PORTRAITS_SRC, name); const isView = name.endsWith("-view.png"); const outName = name.replace(/\.png$/i, ".webp"); const out = join(PORTRAITS_OUT, outName); if (!(await isStale(src, out))) { skipped++; done++; return; } try { await compressImage( src, out, isView ? 2400 : 1600, 82, ); const sSize = await fileSize(src); const oSize = await fileSize(out); totalSrc += sSize; totalOut += oSize; done++; log( `[${done}/${files.length}] ${name} → ${outName} ${fmt(sSize)} → ${fmt(oSize)} (-${Math.round((1 - oSize / sSize) * 100)}%)`, ); } catch (e) { log(`❌ 失败 ${name}: ${e.message}`); } }), ), ); log( `portraits 完成: ${done}/${files.length} (跳过 ${skipped}), 总体积 ${fmt(totalSrc)} → ${fmt(totalOut)}`, ); return { count: done, savedRatio: totalSrc ? 1 - totalOut / totalSrc : 0 }; } async function processVideos() { const tasks = []; // hero pv const heroSrc = join(VIDEOS_SRC, "hero-pv.mp4"); if (existsSync(heroSrc)) { tasks.push({ src: heroSrc, out: join(VIDEOS_OUT, "hero-pv.mp4"), label: "hero-pv.mp4", }); } // artist solos const artistsDir = join(VIDEOS_SRC, "artists"); if (existsSync(artistsDir)) { const files = (await readdir(artistsDir)).filter((f) => /\.mp4$/i.test(f)); for (const name of files) { tasks.push({ src: join(artistsDir, name), out: join(VIDEOS_OUT, "artists", name), label: `artists/${name}`, }); } } log(`videos: 找到 ${tasks.length} 个 mp4 待处理`); // 视频用 libx264 编码很吃 CPU, 串行处理避免互相争抢 let done = 0; let totalSrc = 0; let totalOut = 0; let skipped = 0; for (const t of tasks) { if (!(await isStale(t.src, t.out))) { skipped++; done++; continue; } try { const start = Date.now(); await compressVideo(t.src, t.out, { crf: 28, maxWidth: 1920 }); const sSize = await fileSize(t.src); const oSize = await fileSize(t.out); totalSrc += sSize; totalOut += oSize; done++; log( `[${done}/${tasks.length}] ${t.label} ${fmt(sSize)} → ${fmt(oSize)} (-${Math.round((1 - oSize / sSize) * 100)}%, ${((Date.now() - start) / 1000).toFixed(1)}s)`, ); } catch (e) { log(`❌ 失败 ${t.label}: ${e.message}`); } } log( `videos 完成: ${done}/${tasks.length} (跳过 ${skipped}), 总体积 ${fmt(totalSrc)} → ${fmt(totalOut)}`, ); } async function main() { log("ffmpeg:", FFMPEG_BIN); log("输出目录:", OUT_ROOT); await mkdir(OUT_ROOT, { recursive: true }); const t0 = Date.now(); await processPortraits(); await processVideos(); log(`全部完成, 用时 ${((Date.now() - t0) / 1000).toFixed(1)}s`); log(`下一步: cd ${OUT_ROOT}/.. && tar -cvf cyber-star-assets.tar assets-compressed`); } main().catch((e) => { console.error(e); process.exit(1); });