All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m13s
新增 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>
230 lines
6.6 KiB
JavaScript
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);
|
|
});
|