iye c0bce80dd1
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m13s
chore(tools): asset compression pipeline for TOS bucket upload
新增 tools/asset-pipeline/ 用于把 public/portraits & videos 压缩成桶友好体积:
- sharp:    PNG → WebP q82, 最大宽 1600 (-view 三视图 2400)
- ffmpeg:   MP4 → libx264 CRF 28, 最大宽 1920, AAC 96k, faststart
- pack.mjs: tar -czf 整目录 → cyber-star-assets.tar.gz

效果 (146 portraits + 33 videos):
- 立绘:  768.6MB → 26.8MB  (-96%)
- 视频:  251.7MB → 76.1MB  (-70%)
- 总计:  1020MB  → 103MB   压缩到 1/10, 95s 跑完

输出位于仓库外 ../assets-compressed/ 与 ../cyber-star-assets.tar.gz, 不入 git。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:26:42 +08:00

230 lines
6.6 KiB
JavaScript

/**
* 静态资源压缩 · 把 public/portraits & public/videos 转成桶友好的体积
*
* 输入: <repoRoot>/public/portraits/*.png, public/videos/{hero-pv.mp4, artists/*.mp4}
* 输出: <repoRoot>/../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);
});