图片资产处理管线
Mizuki 构建工具链中的图片资产预处理子系统:以 sharp(libvips 绑定)为核心引擎,通过 scripts/convert-images.js 将 public/ 目录下的 PNG/JPG 资产批量、增量地转换为 WebP,并通过 scripts/prepare-default-images.mjs 把默认横幅(banner)与音乐播放器封面规格化为 Astro 构建可直接引用的镜像资产。
目的与范围(Purpose and Scope)
本页属于 build-toolchain 目录下的叶子页,专门覆盖"图片资产处理管线"这一子系统能力,具体包括:
- 批量 WebP 转换脚本
scripts/convert-images.js:扫描public/下的目标 glob、基于 mtime 的增量跳过、sharp编码参数与体积节省日志。 - 默认资产准备脚本
scripts/prepare-default-images.mjs:banner 镜像复制、音乐封面 192×192 规格化(含"是否已达标"的条件分支)。 - 依赖装配:
sharp的版本声明(package.json)与 pnpm 工作区中的平台二进制(@img/sharp-*)处理。 - Astro 侧图片服务配置:
astro.config.mjs中与回退格式相关的两个开关。
以下内容有意留给兄弟页面,不在本页展开:
- 字体子集化/压缩管线(
scripts/compress-fonts/)——属于另一条独立资产管线; - 内容同步与内容仓库初始化(
scripts/sync-content.js、scripts/init-content-repo.js); - Astro 渲染层(组件、Markdown 渲染)对最终图片的呈现方式。
概述(Overview)
Mizuki 是一个 Astro 静态站点,仓库中存在大量位图资产(首页装饰图、相册照片、日记配图、设备截图、音乐封面等)。位图是静态站点体积的主要来源之一,因此工具链在构建之前就对这些资产做一次统一收敛:
- 格式收敛:把散落在
public/各处的 PNG/JPG 统一转成 WebP。WebP 在同等视觉质量下通常显著小于 PNG/JPEG,脚本会在转换后打印每个文件的体积节省比例,让收益可见。 - 增量执行:转换不是"全量重做"。如果目标
.webp已存在且比源文件新(outputStat.mtime > inputStat.mtime),脚本直接跳过,避免每次运行都重新编码全部图片。 - 规格收敛:音乐播放器封面在
scripts/prepare-default-images.mjs中被统一到 192×192 的正方形规格(fit: "cover"+ 居中裁剪),保证 UI 层拿到的是尺寸确定的资产;如果源图已经不大于 192×192,则原样复制,避免无意义的二次有损压缩。 - 镜像资产:默认 banner(
desktop-banner/mobile-banner各 1–4 号)被从public/assets/复制到src/assets/public/assets/,使源代码可以通过src/assets路径直接 import 这些文件,进入 Astro 的构建期处理。
两条脚本都是独立的 Node ESM 脚本,位于 scripts/ 目录,不依赖任何框架运行时——这意味着它们既可以在本地手动执行,也可以被 CI 流水线直接调用。
架构(Architecture)
下图展示管线的整体结构与数据流。左侧是 public/ 中的源图片,中间是两个脚本各自的处理单元,右侧是产物落点:
要点解读:
- 两条脚本职责分离:
convert-images.js只负责"格式"(PNG/JPG → WebP),prepare-default-images.mjs只负责"规格与摆放位置"(镜像 + 尺寸规格化)。二者没有调用关系,可独立运行、独立失败。 prepare-default-images.mjs的输入是convert-images.js的(部分)输出:它读取的assets/music/cover/*.webp正是转换脚本由assets/music/cover/*.jpg生成的产物;banner 的.webp同理。因此在完整流程上,先跑转换、再跑准备是自然顺序。sharp是唯一的图像处理依赖:编码、缩放、metadata 读取全部经由 sharp(libvips),没有引入 cwebp 等外部 CLI,保证了跨平台行为一致。- 产物有两条去向:
public/内的同名.webp供运行时按 URL 直接访问;src/assets/下的镜像资产供构建期模块引用。
核心实现:convert-images.js
脚本头部与目标清单
脚本是一个普通 ESM 文件,没有 CLI 参数解析——目标集合硬编码在 targets 数组中:
1import sharp from "sharp";
2import { glob } from "glob";
3import { dirname, extname } from "node:path";
4import { fileURLToPath } from "node:url";
5import fs from "node:fs";
6import path from "node:path";
7
8const __dirname = dirname(fileURLToPath(import.meta.url));
9const rootDir = path.join(__dirname, "..");
10const publicDir = path.join(rootDir, "public");
11
12const targets = [
13 "assets/home/*.png",
14 "assets/home/*.jpg",
15 "sakura.png",
16 "images/albums/**/*.jpg",
17 "images/albums/**/*.jpeg",
18 "images/diary/*.jpg",
19 "images/device/*.png",
20 "assets/music/cover/*.jpg",
21];Source: scripts/convert-images.js
设计意图说明:
__dirname通过fileURLToPath(import.meta.url)推导(ESM 下没有__dirname全局),再上溯一层得到仓库根目录,保证脚本从任意 cwd 调用时都指向同一个publicDir。targets里显式列出每个目录及每种扩展名,而不是用**/*.{png,jpg}全仓通配。这是一种保守选择:避免误伤仓库中需要保持原格式的位图(例如 favicon、源设计稿),也让"哪些目录属于站点位图资产"这一业务知识落在一处可审计的清单里。- 注意
images/albums/**同时写了.jpg与.jpeg两种扩展名,因为 glob 的花括号展开之外还需要覆盖历史文件的命名差异;而其他目录只写了一种扩展名,暗示那些目录的命名规范已收敛。
单文件转换:convertToWebP
1async function convertToWebP(inputPath, quality = 85) {
2 const ext = extname(inputPath);
3 const outputPath = inputPath.replace(/\.(png|jpg|jpeg)$/i, ".webp");
4
5 if (fs.existsSync(outputPath)) {
6 const inputStat = fs.statSync(inputPath);
7 const outputStat = fs.statSync(outputPath);
8 if (outputStat.mtime > inputStat.mtime) {
9 console.log(`⏭️ Skipped (exists): ${outputPath}`);
10 return;
11 }
12 }
13
14 try {
15 await sharp(inputPath).webp({ quality, effort: 6 }).toFile(outputPath);
16
17 const inputSize = fs.statSync(inputPath).size;
18 const outputSize = fs.statSync(outputPath).size;
19 const savings = ((1 - outputSize / inputSize) * 100).toFixed(1);
20
21 console.log(`✅ ${inputPath} → ${outputPath}`);
22 console.log(
23 ` ${(inputSize / 1024).toFixed(1)}KB → ${(outputSize / 1024).toFixed(1)}KB (${savings}% saved)`,
24 );
25 } catch (err) {
26 console.error(`❌ Failed: ${inputPath}`, err.message);
27 }
28}Source: scripts/convert-images.js
逐点解读:
- 产物路径即源路径:
inputPath.replace(/\.(png|jpg|jpeg)$/i, ".webp")让.webp与源文件并排存放在同一目录,站点侧只需把引用后缀换成.webp,目录结构零迁移成本。正则带i标志以兼容.PNG、.JPG这类大写扩展名。 - mtime 增量判断:只有当
outputStat.mtime > inputStat.mtime才跳过(严格大于)。使用严格大于意味着"同一秒内同时修改"会被视为需要重转——宁可多转一次,也不让旧产物漏网。这是典型的 Make 风格时间戳依赖判定,简单且无需额外的清单文件/哈希数据库。 - 编码参数:
webp({ quality: 85, effort: 6 })。quality: 85是视觉质量与体积的常见折中;effort: 6提高编码器搜索力度以换取更好的压缩率(WebP 的 effort 范围 0–6,6 为最高、最慢)——对构建期一次性成本换运行时长期收益的取舍。 - 统计与日志:转换后重新
statSync取两个字节数,计算节省百分比。这个日志既是运维可观测性("这次到底省了多少"),也是回归信号(如果某张图 saved 为负数,说明该源图本就高度优化过,值得关注)。 - 错误局部化:
try/catch包住单个文件的转换,失败只打印❌ Failed与err.message,不中断整个批处理。单张坏图不会让其余几百张资产得不到处理。
注意
const ext = extname(inputPath);这一行在当前实现中声明后未被使用,属于遗留变量。
批处理入口:main
1async function main() {
2 const files = await glob(targets, { cwd: publicDir, absolute: true });
3
4 console.log(`Found ${files.length} images to convert\n`);
5
6 for (const file of files) {
7 await convertToWebP(file);
8 }
9
10 console.log("\n✓ Done!");
11}
12
13main().catch(console.error);Source: scripts/convert-images.js
glob(targets, { cwd: publicDir, absolute: true })一次展开所有模式并返回绝对路径,targets数组天然被 glob 当作多模式输入。- 主循环是串行
await,刻意不做并发。原因可以从两个角度理解:其一,libvips/sharp 内部本身有线程池,并发调用多个 sharp 实例的收益有限;其二,串行使日志输出保持稳定的文件顺序,便于人工核对,也避免了大量大图同时解码造成的内存峰值。 - 顶层用
main().catch(console.error)兜底,脚本以非结构化方式失败时仍会打印错误而非静默退出。
核心实现:prepare-default-images.mjs
这个脚本处理的是"默认素材"——站点自带、随仓库分发的横幅与音乐封面。它把 public/assets/ 中已经转成 WebP 的产物镜像到 src/assets/ 下,并对音乐封面做 192×192 规格化。
路径推导
1import { cp, mkdir } from "node:fs/promises";
2import { dirname, resolve } from "node:path";
3import { fileURLToPath } from "node:url";
4import sharp from "sharp";
5
6const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7const publicAssets = resolve(projectRoot, "public", "assets");
8const mirroredAssets = resolve(
9 projectRoot,
10 "src",
11 "assets",
12 "public",
13 "assets",
14);
15const musicCoverOutput = resolve(
16 projectRoot,
17 "src",
18 "assets",
19 "music",
20 "cover",
21);Source: scripts/prepare-default-images.mjs
镜像目标是 src/assets/public/assets/ 这样一个"路径复刻"的目录。这样做的意图是:src/assets 是 Astro/Vite 的构建期资产根,组件可以 import 这些文件让构建器哈希、打包;而目录层级与 public/assets 保持一致,使源码中的相对引用路径和部署后的 URL 路径形状相同,降低心智负担。
Banner 镜像
1for (const directory of ["desktop-banner", "mobile-banner"]) {
2 const targetDirectory = resolve(mirroredAssets, directory);
3 await mkdir(targetDirectory, { recursive: true });
4 for (let index = 1; index <= 4; index += 1) {
5 await cp(
6 resolve(publicAssets, directory, `${index}.webp`),
7 resolve(targetDirectory, `${index}.webp`),
8 );
9 }
10}Source: scripts/prepare-default-images.mjs
- 每类 banner 固定 4 张(1–4 号),对应站点首页轮播/随机展示的素材数量。脚本按编号循环
cp,recursive: true的mkdir保证目标目录存在。 - 这里使用纯复制而非重编码——文件已是 WebP,再做一次转码只会引入不必要的质量损失,
cp是无损且最快的选项。
音乐封面规格化
1await mkdir(musicCoverOutput, { recursive: true });
2for (const name of ["cl", "dazbee", "hitori", "xryx"]) {
3 const source = resolve(publicAssets, "music", "cover", `${name}.webp`);
4 const target = resolve(musicCoverOutput, `${name}.webp`);
5 const metadata = await sharp(source).metadata();
6
7 if ((metadata.width ?? 0) <= 192 && (metadata.height ?? 0) <= 192) {
8 await cp(source, target);
9 } else {
10 await sharp(source)
11 .resize(192, 192, { fit: "cover", position: "centre" })
12 .webp({ quality: 85 })
13 .toFile(target);
14 }
15}
16
17console.log("Prepared mirrored banners and player-sized music covers.");Source: scripts/prepare-default-images.mjs
这段是全脚本最体现"条件分支设计"的地方:
- 先读 metadata,再决定处理方式:
sharp(source).metadata()只解析头部,不解码像素,成本极低。据此判断源图是否已满足width <= 192 && height <= 192。 - 达标即复制,不达标才缩放:
- 达标分支
cp(source, target)——尺寸已合规时,重编码纯属浪费且会二次有损; - 不达标分支用
.resize(192, 192, { fit: "cover", position: "centre" }):fit: "cover"表示"填满 192×192 并裁掉超出部分",position: "centre"指定居中裁剪。对封面这类需要正方形显示的场景,cover 模式保证输出形状确定、主体居中。 - 缩放后再以
.webp({ quality: 85 })落盘,质量参数与convert-images.js保持一致,统一整条管线的编码口径。
- 达标分支
- 空值防御:
metadata.width ?? 0把"读不到宽高"的情形归入"不达标"一侧,走缩放/重编码路径兜底,而不是抛出 TypeError。 - 封面名单
["cl", "dazbee", "hitori", "xryx"]与convert-images.js的assets/music/cover/*.jpg目标相衔接:前者(JPG)先被转成.webp,后者再消费这些.webp。
核心流程(Core Flow)
把两条脚本放进一次完整的资产准备流程,时序如下:
流程顺序的"为什么":prepare-default-images.mjs 的输入是 public/assets/ 下的 .webp,而那些 .webp 由 convert-images.js 生成。若顺序颠倒,准备脚本要么找不到文件(cp 报错、metadata() 抛异常),要么消费的是更早一轮的过期产物。因此"先转换、后准备"是这条管线唯一的正确顺序。
配置选项(Configuration Options)
图片管线没有独立配置文件,参数以代码内常量形式存在;另有两处仓库级配置影响其运行:
| 选项 / 配置 | 位置 | 类型 | 默认 / 当前值 | 说明 |
|---|---|---|---|---|
quality | scripts/convert-images.js 的 convertToWebP 形参 | number | 85 | WebP 编码质量;调用处未传参,实际始终为 85 |
effort | scripts/convert-images.js | number | 6 | WebP 编码器努力级别(0–6),6 最慢但压缩率最好 |
targets | scripts/convert-images.js | string[] | 8 条 glob 模式 | 待转换位图清单,新增目录需改此数组 |
| banner 目录与编号范围 | scripts/prepare-default-images.mjs | string[] / 区间 | desktop-banner、mobile-banner × 1–4 | 镜像到 src/assets/public/assets/ 的素材 |
| 封面名单 | scripts/prepare-default-images.mjs | string[] | cl、dazbee、hitori、xryx | 参与规格化的音乐封面 |
| 封面目标尺寸 | scripts/prepare-default-images.mjs | number×2 | 192×192(fit: cover, position: centre) | 播放器封面规格 |
| 封面达标阈值 | scripts/prepare-default-images.mjs | number | 宽、高均 ≤ 192 | 满足则 cp,否则 resize+重编码 |
sharp 版本 | package.json dependencies | semver | ^0.35.3 | 唯一的图像处理依赖 |
onlyBuiltDependencies.sharp | pnpm-workspace.yaml | boolean | true | 允许 pnpm 执行 sharp 的安装脚本 |
@img/sharp-* 平台包 | pnpm-workspace.yaml | 版本列表 | darwin/linux/freebsd 等多平台 0.35.0 | 强制收录各平台二进制,保证跨平台可复现安装 |
pnpm-workspace.yaml 中显式列出 @img/sharp-darwin-arm64、@img/sharp-linux-x64 等一长串平台二进制包,配合 sharp: true 的构建许可。设计意图是锁定安装产物:不依赖运行时按平台动态解析 optional dependencies,而是把所有平台二进制固定进锁文件,使任意 CI 环境(macOS arm64、linux x64、musl 等)都能离线装出可用的 sharp。这对"图片转换脚本必须在 CI 里能跑"是关键前提。
astro.config.mjs 中另有 fallbacks: [] 与 optimizedFallbacks: false(第 80–81 行附近)两个图片服务相关开关——它们控制 Astro 图片服务是否生成回退格式,与预处理脚本职责互补但分属渲染层,此处仅作交叉提示,详见渲染相关文档。
API 参考(API Reference)
两条脚本均为顶层执行的脚本(无导出),下面列出其内部函数的实际签名与行为。
convertToWebP(inputPath: string, quality?: number): Promise<void>
- 描述:把单张 PNG/JPG 编码为同目录同名
.webp;若目标已比源新则跳过。 - 参数:
inputPath(string,必需):相对仓库根的绝对图片路径,扩展名须匹配/\.(png|jpg|jpeg)$/i。quality(number,可选,默认85):传入 sharpwebp()的质量值。
- 返回:
Promise<void>,无返回值;进度与结果仅通过console.log/console.error输出。 - 抛出:不向调用方抛出——内部
try/catch捕获转换异常并打印❌ Failed后继续。文件系统statSync异常未被该 try 块覆盖。 - 副作用:写出
outputPath;读取两次statSync用于日志。
main(): Promise<void>
- 描述:
convert-images.js的批处理入口。glob展开targets后串行调用convertToWebP。 - 返回:
Promise<void>;脚本末尾main().catch(console.error)兜底打印顶层错误。
prepare-default-images.mjs 顶层流程(无函数封装)
- 描述:模块加载即执行的两段式流程——banner 镜像、封面规格化。
- 抛出:无任何 try/catch 包裹;
cp/sharp().metadata()/toFile的异常会直接以未捕获 Promise 拒绝终止脚本(退出码非 0)。 - 依赖顺序:要求
public/assets/{desktop-banner,mobile-banner}/1..4.webp与public/assets/music/cover/{cl,dazbee,hitori,xryx}.webp已存在——即需先运行convert-images.js。
失败模式、边界与并发(Failure Modes, Edge Cases & Concurrency)
| 场景 | 行为 | 后果与应对 |
|---|---|---|
| 单张图片损坏/不支持 | convertToWebP 的 try/catch 打印 ❌ Failed 并继续 | 批处理不中断;需人工扫日志确认缺失产物 |
prepare-default-images.mjs 输入缺失 | 无 try/catch,cp/metadata() 直接抛错终止 | 明确失败信号,提示需先运行转换脚本 |
目标 .webp 已存在且更新 | mtime 比较命中,打印 ⏭️ Skipped (exists) | 增量语义,幂等重跑安全 |
| mtime 相同(同秒修改) | 严格大于不成立,会重新转换 | 宁可重转也不漏转的保守取向 |
| 封面 metadata 读不到宽高 | ?? 0 归零 → 判为不达标 → 走 resize 兜底 | 空值防御,避免 TypeError |
| 源图本就小于 192×192 | 直接 cp,不重编码 | 避免二次有损;注意封面被放大场景不存在(cover 只缩不放大到指定外框内的语义由 sharp 决定) |
| 转换后体积反而变大 | savings 为负数并照常打印 | 可观测的回归信号,便于人工筛除极小或已高度优化的源图 |
大小写扩展名(.PNG) | 路径替换正则带 i,输出 .webp;但 targets glob 不匹配大写 | glob 层不匹配大写,实际不会进入流程——清单按小写规范命名 |
| 并发 | 主循环串行 await,脚本间也无并发 | 无锁竞争、内存峰值可控、日志有序;代价是总时长线性 |
一致性边界:mtime 判定基于本地文件系统时钟。若源文件被 checkout 回旧版本(mtime 变旧)而产物保留,会出现"产物比源新"的误跳过;反之若 touch 了源文件,则全量重转。这是时间戳方案固有的精度边界,脚本未做内容哈希校验。
性能与运维(Performance & Operations)
- 编码成本集中且一次性:
effort: 6是 WebP 最慢档位,首跑成本最高;此后依赖 mtime 增量,日常重跑只处理新增/变更文件,成本近似为零。 - 内存友好:sharp 流式处理单图、主循环串行,峰值内存与最大单图成正比,而非与总量成正比。
- 可观测性:
Found N images to convert、逐文件✅/⏭️/❌与节省百分比、结尾✓ Done!构成完整的 CLI 进度输出,适合直接接入 CI 日志。 - 跨平台:依赖 sharp 的预编译平台二进制(pnpm 工作区已锁全平台包),无系统级
cwebp依赖,CI 镜像无需额外安装原生工具。 - 运维顺序:
convert-images.js→prepare-default-images.mjs;两者都是幂等脚本(跳过/覆盖语义明确),可安全反复执行。
扩展点(Extension Points)
- 新增资产目录:在
scripts/convert-images.js的targets数组追加 glob 模式即可纳入管线;不需要改动任何处理逻辑。 - 调整质量档位:
convertToWebP的quality形参已支持注入,只是当前调用点用默认值;若要做多档位(如相册高质量、装饰图低质量),可在main中按目录映射 quality。 - 新增默认素材:banner 在目录循环与编号循环中扩展;封面在名单数组中追加
public/assets/music/cover/<name>.webp对应条目。 - 避免扩展的方向:管线刻意不解析 CLI 参数、不写状态清单文件、不并发。若需要哈希级增量或并发加速,属于对脚本的改造而非配置,应保持"简单串行、时间戳增量"的既有风格以维持可审计性。
相关链接(Related Links)
- 源码:scripts/convert-images.js
- 源码:scripts/prepare-default-images.mjs
- 依赖声明:package.json
- 平台二进制锁定:pnpm-workspace.yaml
- 渲染侧图片服务配置:astro.config.mjs
- 兄弟页:字体压缩管线(
scripts/compress-fonts/)、内容同步(scripts/sync-content.js)分属其他build-toolchain叶子页,本页不展开。