Repository Wiki
LyraVoid/Mizuki

图片资产处理管线

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 静态站点,仓库中存在大量位图资产(首页装饰图、相册照片、日记配图、设备截图、音乐封面等)。位图是静态站点体积的主要来源之一,因此工具链在构建之前就对这些资产做一次统一收敛:

  1. 格式收敛:把散落在 public/ 各处的 PNG/JPG 统一转成 WebP。WebP 在同等视觉质量下通常显著小于 PNG/JPEG,脚本会在转换后打印每个文件的体积节省比例,让收益可见。
  2. 增量执行:转换不是"全量重做"。如果目标 .webp 已存在且比源文件新(outputStat.mtime > inputStat.mtime),脚本直接跳过,避免每次运行都重新编码全部图片。
  3. 规格收敛:音乐播放器封面在 scripts/prepare-default-images.mjs 中被统一到 192×192 的正方形规格(fit: "cover" + 居中裁剪),保证 UI 层拿到的是尺寸确定的资产;如果源图已经不大于 192×192,则原样复制,避免无意义的二次有损压缩。
  4. 镜像资产:默认 banner(desktop-banner/mobile-banner 各 1–4 号)被从 public/assets/ 复制到 src/assets/public/assets/,使源代码可以通过 src/assets 路径直接 import 这些文件,进入 Astro 的构建期处理。

两条脚本都是独立的 Node ESM 脚本,位于 scripts/ 目录,不依赖任何框架运行时——这意味着它们既可以在本地手动执行,也可以被 CI 流水线直接调用。

架构(Architecture)

下图展示管线的整体结构与数据流。左侧是 public/ 中的源图片,中间是两个脚本各自的处理单元,右侧是产物落点:

Loading diagram...

要点解读:

  • 两条脚本职责分离: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 数组中:

javascript
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

javascript
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

逐点解读:

  1. 产物路径即源路径:inputPath.replace(/\.(png|jpg|jpeg)$/i, ".webp") 让 .webp 与源文件并排存放在同一目录,站点侧只需把引用后缀换成 .webp,目录结构零迁移成本。正则带 i 标志以兼容 .PNG、.JPG 这类大写扩展名。
  2. mtime 增量判断:只有当 outputStat.mtime > inputStat.mtime 才跳过(严格大于)。使用严格大于意味着"同一秒内同时修改"会被视为需要重转——宁可多转一次,也不让旧产物漏网。这是典型的 Make 风格时间戳依赖判定,简单且无需额外的清单文件/哈希数据库。
  3. 编码参数:webp({ quality: 85, effort: 6 })。quality: 85 是视觉质量与体积的常见折中;effort: 6 提高编码器搜索力度以换取更好的压缩率(WebP 的 effort 范围 0–6,6 为最高、最慢)——对构建期一次性成本换运行时长期收益的取舍。
  4. 统计与日志:转换后重新 statSync 取两个字节数,计算节省百分比。这个日志既是运维可观测性("这次到底省了多少"),也是回归信号(如果某张图 saved 为负数,说明该源图本就高度优化过,值得关注)。
  5. 错误局部化:try/catch 包住单个文件的转换,失败只打印 ❌ Failed 与 err.message,不中断整个批处理。单张坏图不会让其余几百张资产得不到处理。

注意 const ext = extname(inputPath); 这一行在当前实现中声明后未被使用,属于遗留变量。

批处理入口:main

javascript
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 规格化。

路径推导

javascript
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 路径形状相同,降低心智负担。

javascript
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 是无损且最快的选项。

音乐封面规格化

javascript
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

这段是全脚本最体现"条件分支设计"的地方:

  1. 先读 metadata,再决定处理方式:sharp(source).metadata() 只解析头部,不解码像素,成本极低。据此判断源图是否已满足 width <= 192 && height <= 192。
  2. 达标即复制,不达标才缩放:
    • 达标分支 cp(source, target)——尺寸已合规时,重编码纯属浪费且会二次有损;
    • 不达标分支用 .resize(192, 192, { fit: "cover", position: "centre" }):fit: "cover" 表示"填满 192×192 并裁掉超出部分",position: "centre" 指定居中裁剪。对封面这类需要正方形显示的场景,cover 模式保证输出形状确定、主体居中。
    • 缩放后再以 .webp({ quality: 85 }) 落盘,质量参数与 convert-images.js 保持一致,统一整条管线的编码口径。
  3. 空值防御:metadata.width ?? 0 把"读不到宽高"的情形归入"不达标"一侧,走缩放/重编码路径兜底,而不是抛出 TypeError。
  4. 封面名单 ["cl", "dazbee", "hitori", "xryx"] 与 convert-images.js 的 assets/music/cover/*.jpg 目标相衔接:前者(JPG)先被转成 .webp,后者再消费这些 .webp。

核心流程(Core Flow)

把两条脚本放进一次完整的资产准备流程,时序如下:

Loading diagram...

流程顺序的"为什么":prepare-default-images.mjs 的输入是 public/assets/ 下的 .webp,而那些 .webp 由 convert-images.js 生成。若顺序颠倒,准备脚本要么找不到文件(cp 报错、metadata() 抛异常),要么消费的是更早一轮的过期产物。因此"先转换、后准备"是这条管线唯一的正确顺序。

配置选项(Configuration Options)

图片管线没有独立配置文件,参数以代码内常量形式存在;另有两处仓库级配置影响其运行:

选项 / 配置位置类型默认 / 当前值说明
qualityscripts/convert-images.js 的 convertToWebP 形参number85WebP 编码质量;调用处未传参,实际始终为 85
effortscripts/convert-images.jsnumber6WebP 编码器努力级别(0–6),6 最慢但压缩率最好
targetsscripts/convert-images.jsstring[]8 条 glob 模式待转换位图清单,新增目录需改此数组
banner 目录与编号范围scripts/prepare-default-images.mjsstring[] / 区间desktop-banner、mobile-banner × 1–4镜像到 src/assets/public/assets/ 的素材
封面名单scripts/prepare-default-images.mjsstring[]cl、dazbee、hitori、xryx参与规格化的音乐封面
封面目标尺寸scripts/prepare-default-images.mjsnumber×2192×192(fit: cover, position: centre)播放器封面规格
封面达标阈值scripts/prepare-default-images.mjsnumber宽、高均 ≤ 192满足则 cp,否则 resize+重编码
sharp 版本package.json dependenciessemver^0.35.3唯一的图像处理依赖
onlyBuiltDependencies.sharppnpm-workspace.yamlbooleantrue允许 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):传入 sharp webp() 的质量值。
  • 返回: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 参数、不写状态清单文件、不并发。若需要哈希级增量或并发加速,属于对脚本的改造而非配置,应保持"简单串行、时间戳增量"的既有风格以维持可审计性。