如何用 jpgboost-cli 在 CI 流水线中自动压缩图片

一个不断堆积未压缩 JPEG 和 PNG 的仓库,既会拖累 LCP,也会让每一次克隆变得更重。jpgboost-cli 可以把压缩这一步直接自动化到 CI 流水线里,不必依赖每位贡献者的自觉。本文介绍它与 GitLab CI 的集成,比较两种截然相反的策略,并梳理那些在生产环境里可能代价高昂的坑。

未压缩图片的真实代价

每一张未经压缩就提交的图片,都会以包含其内容的 blob 形式在 Git 历史中留下痕迹。即使这张图片后来被更轻的版本替换,旧的 blob 依然留在历史里。git clone 也会把这段历史一并拉下来。因此旧图片的体积会持续压在仓库上,哪怕它们早已不再使用。

在一个几个月里不断累积截图、营销素材或设计导出文件的仓库中,这些死重量很快就能达到几百 MB。这会拖慢 CI 中的克隆速度,并增加所有贡献者的带宽消耗。

在生产端,问题并不止于仓库。尺寸过大的图片会直接损害 Largest Contentful Paint(LCP),尤其当首屏中最大的那张图片正好是决定该指标的元素时。压缩带来的收益可能相当可观:在 jpgboost-cli 官方文档自己的例子里,一张 4.2 MB 的 JPEG 降到了仅 890 KB,减少了 79 %。

手动压缩无法规模化,它依赖个人习惯。每一次合并请求,每位贡献者都得记得在提交前优化图片。实际情况是,一旦时间紧张或事情变急,这一步很容易被跳过。问题通常要到几周之后才被发现,等到某次审计或性能报告揭示出累积的债务。

为什么在 CI 里自动化,而不是本地或服务端

在本地压缩图片,比如用 Git 的 pre-commit 钩子,始终取决于机器和贡献者的自律。钩子可以被绕过、被卸载,或者在某位直接提交文件的设计师机器上压根就没装。

而服务端压缩则介入得太晚。图片已经被索引,甚至可能已经至少被分发过一次。也就是说,问题对最早的访客是可见的,而且此后每次部署都得重新做一遍优化。

CI 是最可靠的检查点。它在每一次合并请求时运行,不依赖本地机器的配置,并在作业日志里留下可见、可追溯的结果。正是这个唯一的必经关卡让自动化变得划算,尽管它也带来了从一开始就必须计入的基础设施成本。

这个成本在这里更重,因为 jpgboost-cli 并不是一个可以用包管理器安装的独立二进制程序。它随 JPGBoost.app 一起分发,仅在 macOS 15 及以上版本运行,并且需要 Pro 许可证。

因此本文余下的内容建立在一个具体前提上:一台持久化的自托管 macOS Runner,以获得稳定的环境,并让 JPGBoost.app 的安装状态在作业之间保持下来。

安装 jpgboost-cli 并在本地测试

无论是在 Runner 上还是本地测试,二进制程序都位于应用程序包内部。只需创建一次符号链接,之后就能从任意目录调用它。

# 创建一次符号链接,之后即可从任意目录调用 jpgboost-cli
sudo ln -s /Applications/JPGBoost.app/Contents/MacOS/jpgboost-cli /usr/local/bin/jpgboost-cli

# 确认命令有响应
jpgboost-cli --help

随后在 Runner 上手动激活一次 Pro 许可证。这一步绝不会发生在流水线内部。如果这是激活该许可证的第一次安装,请使用购买后通过邮件收到的令牌。

# 本机标识符,必要时可提供给技术支持
jpgboost-cli --machine-id

# 首次激活,使用购买后通过邮件收到的令牌
jpgboost-cli --activate ACT-XXXX-XXXX-XXXX-XXXX-XXXX

如果该许可证已经在别处激活过(通常是在应用中,用于个人使用),--activate 就不再是正确的做法了。Runner 需要加入这个已有的许可证,而不是激活一个新的。在已激活的设备上生成一个配对码,然后在 Runner 上使用它。

# 在已激活的设备上(应用或另一个 CLI)生成配对码
jpgboost-cli --add-device

# 在 Runner 上用这个配对码加入已有许可证
jpgboost-cli --pair XXXX-XXXX

无论用哪种方式,这次激活都会占用 Pro 许可证所涵盖的两次安装名额之一。由于应用和 CLI 算作两次独立安装,如果每个作业都激活一次,名额很快就会耗尽:仅仅两次运行,许可证就已经被用光了。这正是 Runner 必须持久化的原因,好让激活状态在作业之间保留下来。

继续之前,先在本地快速测试一下。

# 将两个文件以质量 60 转换为 WebP
jpgboost-cli photo1.jpg photo2.png --quality 60 --format webp --output ./compressed

配置 GitLab(Runner 与推送令牌)

在粘贴下面的 .gitlab-ci.yml 之前,需要在 GitLab 一侧一次性准备好两件事:注册 Runner,以及创建修正作业推送时所需的令牌。

注册 Runner

在选定作为持久化 Runner 的那台 Mac 上(也就是上一节里已经安装好 jpgboost-cli 及其许可证的那台),用两个作业都会使用的 macos 标签注册 Runner。

gitlab-runner register \
  --url https://gitlab.com \
  --token <PROJECT_REGISTRATION_TOKEN>

注册令牌位于 Settings > CI/CD > Runners > New project runner(界面会为这次注册生成一个一次性令牌,而不是旧的共享注册令牌)。这里 --executor shell 很关键:与 Docker executor 不同,它直接在 Runner 的系统上执行命令,这正是能够在作业之间找到已安装的 jpgboost-cli 和已激活的许可证、而不是每次都从干净环境重新开始的关键。

GitLab 的 Create project runner 页面,Tags 字段填入 macos,底部是 Create runner 按钮
这里填写的标签,正是下文作业所引用的那个标签
GitLab 的 Register runner 页面,旁边是正在运行 gitlab-runner register 的终端,交互式地询问 Runner 名称和 executor,已输入 shell
gitlab-runner register 会交互式地先问 Runner 名称,再问 executor;最后这个问题请回答 shell
GitLab 的 CI/CD 设置页面,显示一个已分配的项目 Runner,处于在线且空闲状态,带有 macos 标签
注册完成后,Runner 会在项目的 CI/CD 设置中显示为在线且空闲

创建推送令牌

fix-image-weight 作业会向合并请求的分支推送一个提交。每个作业自动获得的 CI_JOB_TOKEN 对受保护分支没有所需的写权限,因此需要一个专用令牌。

Project Access Token(项目的 Settings > Access Tokens)理论上是最干净的选择:它绑定到项目而非某个账号,创建者离开后依然有效。但在免费方案的个人命名空间下,GitLab.com 并不提供这项功能,它仅面向付费群组。这也是最常见的、转而使用 Personal Access Token 的原因。

Personal Access Token 是在用户账号设置中创建的,而不是项目设置(Avatar > Edit profile > Access Tokens),配置如下:

  • 作用域 write_repository
  • 与你的令牌轮换策略相符的有效期
GitLab 的 Personal access tokens 页面,正在创建名为 push-images-ci 的令牌,作用域为 Write repository,有效期一年
该令牌只需要 Write repository 作用域,别的都不需要

选择这条路之前需要知道的代价:令牌绑定在创建它的账号上。如果该账号被停用、失去项目访问权,或者这个人离开了团队,修正作业就再也推不上去了,而且事先不会有任何警告。在团队项目中,最好用一个专门的服务账号来创建,而不是某位贡献者的个人账号。

生成的令牌只会显示一次:请立即复制,然后在项目的 Settings > CI/CD > Variables 中以 PUSH_TOKEN 为名添加。如果你的合并请求指向受保护分支,请勾选 MaskedProtected 两个选项。

GitLab 的 CI/CD Settings 页面,添加项目变量的面板中键为 PUSH_TOKEN,已选中 Masked 选项,值被遮蔽
该变量名为 PUSH_TOKEN,会在后文的 .gitlab-ci.yml 中用到

如果合并请求的源分支本身也受保护,那么与令牌关联的账号还需要具备直接向其推送的权限,位置在 Settings > Repository > Protected branches > Allowed to push and merge;否则即使令牌有效,作业中的 git push 也会因权限被拒而失败。

.gitlab-ci.yml 文件该放在哪里

默认情况下,GitLab 只在一个位置寻找流水线:仓库根目录下、与 README 同级、名字恰好为 .gitlab-ci.yml 的文件(含开头的点)。放在子目录里的文件,或者名字不同的文件,都会被直接忽略:没有任何报错提示,项目只是表现得像根本没有配置过 CI 一样。

如果确实需要别的位置,比如多个仓库共用的流水线文件,或者 monorepo 结构,可以在 Settings > CI/CD > General pipelines > CI/CD configuration file 中指向另一个路径,甚至可以指向另一个项目,语法为 路径/文件.yml@群组/项目:分支。除了这种特殊情况,请把这一栏留空,并把文件保留在根目录。

文件一旦提交并推送,就不需要任何手动启用:GitLab 会在下一个符合文件中某条规则的事件上自动识别它,这里就是通过 $CI_PIPELINE_SOURCE == "merge_request_event" 触发的合并请求的创建或更新。结果会出现在项目的 Pipelines 标签页,以及合并请求中同名的标签页里。如果想在推送前就检查语法,而不是等第一条流水线失败时才发现,内置编辑器(项目菜单中的 Build > Pipeline editor)提供了 Validate 按钮,它会调用 GitLab 的 CI 语法检查器,而不会真的触发一条流水线。

状态为 Passed 的 GitLab 流水线页面,check-image-weight 和 fix-image-weight 两个作业均已成功
流水线在合并请求上自动触发,包含 verificationcorrection 两个作业

与 GitLab CI 集成

下面的作业指向带有 macos 标签的 Runner,也就是前文所说、并已在流水线之外完成激活的那台持久化自托管 Runner。它只在合并请求的流水线中运行,并把下一节介绍的两种模式结合在一起:先校验体积,再自动修正。

# .gitlab-ci.yml
stages:
  - verification
  - correction

variables:
  # 压缩后图片超过该阈值即判定作业失败(单位 KB)
  THRESHOLD_KB: "6000"
  # 完整克隆:否则浅克隆可能不包含
  # CI_MERGE_REQUEST_DIFF_BASE_SHA,导致下面的 "git diff" 失败。
  GIT_DEPTH: "0"

check-image-weight:
  stage: verification
  tags: [macos]
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - |
      # 继续之前先确认 jpgboost-cli 有响应
      jpgboost-cli --help > /dev/null

      # 只关注本次合并请求中新增或修改的图片
      # (当前范围:.jpg 和 .png)
      FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
      if [ -z "$FILES" ]; then
        echo "本次合并请求没有修改任何图片。"
        exit 0
      fi

      # 试压缩为 WebP 并输出到临时目录,用于比较体积
      mkdir -p /tmp/check-images
      # (不用 "readarray":macOS 至今仍自带 bash 3.2,
      # 该内建命令直到 bash 4 才引入,在这里并不存在)
      FILES_ARR=()
      while IFS= read -r LINE; do
        FILES_ARR+=("$LINE")
      done <<< "$FILES"
      jpgboost-cli "${FILES_ARR[@]}" --format webp --quality 75 --jobs 4 --output /tmp/check-images --json > /tmp/report.json

      # 若压缩后仍有图片超过阈值,则判定作业失败
      THRESHOLD_BYTES=$((THRESHOLD_KB * 1000))
      OVER_LIMIT=$(jq --argjson threshold "$THRESHOLD_BYTES" '[.[] | select(.compressedSizeBytes > $threshold)] | length' /tmp/report.json)
      if [ "$OVER_LIMIT" -gt 0 ]; then
        echo "压缩后仍超过 $THRESHOLD_KB KB 的图片有 $OVER_LIMIT 张:"
        jq --argjson threshold "$THRESHOLD_BYTES" -r '.[] | select(.compressedSizeBytes > $threshold) | .path' /tmp/report.json
        exit 1
      fi
      echo "所有被修改的图片都在 $THRESHOLD_KB KB 阈值之内。"

fix-image-weight:
  stage: correction
  tags: [macos]
  # 与 "verification" 阶段解耦:否则在唯一真正需要它的场景下
  # (check-image-weight 失败时),这个作业永远都跑不到。
  needs: []
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - |
      # 只处理 .jpg/.png,参见 check-image-weight 中的同类注释。
      FILES=$(git diff --name-only --diff-filter=ACM "$CI_MERGE_REQUEST_DIFF_BASE_SHA" -- '*.jpg' '*.png')
      if [ -z "$FILES" ]; then
        echo "本次合并请求没有修改任何图片。"
        exit 0
      fi

      rm -f /tmp/report.json
      THRESHOLD_BYTES=$((THRESHOLD_KB * 1000))
      MIN_QUALITY=20
      REMAINING=""

      # 把每个文件都转换为 JPEG(--format jpeg,即默认值):对照片而言,
      # PNG 的压缩效果明显不如 JPEG(见「常见的坑」),而 JPEG 正是这里
      # 期望的目标格式。不适用于带透明通道的 PNG(JPEG 没有 alpha 通道),
      # 对照片来说不成问题,但如果哪天有 logo 或图标要走这条流水线,
      # 就需要重新考虑。
      #
      # 只要结果仍超过阈值,就逐级降低质量:固定质量(60)跑一遍
      # 并不总是够用。
      while IFS= read -r FILE; do
        FOLDER=$(dirname "$FILE")
        QUALITY=60
        while :; do
          RESULT=$(jpgboost-cli "$FILE" --format jpeg --quality "$QUALITY" --output "$FOLDER" --json)
          SIZE=$(echo "$RESULT" | jq '.[0].compressedSizeBytes // 0')
          if [ "$SIZE" -le "$THRESHOLD_BYTES" ] || [ "$QUALITY" -le "$MIN_QUALITY" ]; then
            break
          fi
          QUALITY=$((QUALITY - 15))
          if [ "$QUALITY" -lt "$MIN_QUALITY" ]; then
            QUALITY=$MIN_QUALITY
          fi
        done
        echo "$RESULT" >> /tmp/report.json
        # 输出文件始终是 <基名>.jpg:如果原文件本来不是这个扩展名
        # (例如 .png),就需要把原文件从仓库中删除,否则两者会并存,
        # 旧的那个将长期保持未压缩状态。
        case "$FILE" in
          *.jpg) : ;;
          *) git rm -q "$FILE" ;;
        esac
        if [ "$SIZE" -gt "$THRESHOLD_BYTES" ]; then
          echo "$FILE 压缩后仍超过 $THRESHOLD_KB KB(质量 $QUALITY,$((SIZE / 1000)) KB):需要手动缩减。"
          REMAINING="$REMAINING $FILE"
        fi
      done <<< "$FILES"

      # 压缩收益,可在作业日志中查看(见「衡量收益」)。
      # -s:/tmp/report.json 中每个文件对应一个 JSON 数组(上面循环里
      # 每次 ">>" 写入一个),"add" 会先把它们合并成一个再求和。
      echo "--- 压缩收益 ---"
      jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) 字节 (\(.ratio))"' /tmp/report.json
      echo "压缩前合计:$(jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json) 字节"
      echo "压缩后合计:$(jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json) 字节"

      # git diff --quiet 察觉不到被 git rm 删除的 .png 被一个未跟踪的
      # .jpg 取代:status --porcelain 同样涵盖未跟踪文件。
      if [ -z "$(git status --porcelain)" ]; then
        echo "没有需要提交的内容,所有图片此前就已压缩过。"
        exit 0
      fi

      git config user.name "jpgboost-ci"
      git config user.email "ci@example.com"
      git add -A
      # [skip ci]:没有它,这次推送会触发新的 merge_request_event 流水线,
      # 后者又推送一个修正提交,如此往复(无限循环)。
      git commit -m "用 jpgboost-cli 压缩被修改的图片 [skip ci]"
      # PUSH_TOKEN 是一个 personal access token(作用域 write_repository),
      # 以受遮蔽的 CI/CD 变量形式保存:默认的 CI_JOB_TOKEN 不足以推送到
      # 受保护分支。
      git remote set-url origin "https://gitlab-ci-token:${PUSH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
      git push origin "HEAD:${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"

      # 无论如何都会推送当前能达到的最好结果(总比什么都不做强),但只要
      # 还有文件在最低质量下仍超过阈值,作业就判定失败,让问题保持可见,
      # 而不是被悄悄接受。
      if [ -n "$REMAINING" ]; then
        echo "压缩后仍然过大的文件:$REMAINING"
        exit 1
      fi

校验模式还是修正模式

上一节的两个作业展示了把 jpgboost-cli 接入 GitLab 合并请求的两种相反做法。一种是拦下来,另一种是替贡献者改好。

校验模式下,check-image-weight 作业什么都不改。它把每张被修改的图片压缩到一个临时目录,将得到的体积与设定的阈值比较,一旦超出就让 CI 失败。仓库保持原样。重新处理图片并再次提交是贡献者的事;流水线只是在此之前拒绝合并。

修正模式下,fix-image-weight 作业更进一步。它直接在仓库中就地压缩每张图片,然后提交并把结果推送到合并请求的分支上。贡献者不再需要返工,但 Git 历史里会多出一个并非他们自己写下的提交。

对比项校验模式修正模式
目标图片超过阈值时拦下 CI压缩并向合并请求推送一个提交
机制--json + jq,与设定阈值比较,然后显式 exit 1(并没有内置的阈值参数)直接压缩、就地写入,然后推送
对仓库的影响没有影响,作业只做观察合并请求中新增一个自动提交
对贡献者的影响需要自己修正并重新提交无需再做任何事
所需认证不需要存放在受遮蔽变量中的 project access token 或 deploy token
代价产生摩擦,手动修正的负担留给贡献者自动改写 Git 历史,与受保护分支存在冲突风险

实际使用中,校验模式适合那些希望在图片压缩入库之前,仍由自己把握编辑决策(裁剪、修图、格式选择)的团队。修正模式适合那些宁愿彻底不去操心的团队,代价是历史中多一个自动提交,以及需要维护一个推送令牌。

优化作业

两个作业都已经通过 git diff --name-only --diff-filter=ACM 把工作限定在真正被修改的文件上,而不是每次流水线都重新扫描整个仓库。在积累了数百张图片的仓库里,运行时间的差距相当可观。

在持久化的自托管 Runner 上,缓存的意义不同。GitLab CI 的 cache: 指令主要是为那种每个作业都从零开始的临时 Runner 恢复依赖而存在的。而这里 JPGBoost.app 已经装在 Runner 上,无需重新下载。加一个 cache: 块,并不能带来 Runner 本地存储之外的任何额外好处。

--json 选项提供了构建幂等机制所需的信息。只要把 compressedSizeBytes、或者更好的是文件的哈希值,在多次运行之间保留下来,就可以识别出未发生变化的文件,避免每条流水线都重新压缩一遍。

至于并行处理,则由 --jobs 原生支持。多个文件可以同时处理,各自独立地解码和释放。因此内存占用主要取决于 --jobs 的取值,而不是待处理文件的总数。在 12 个文件的批次上,文档给出的数据是 --jobs 8 相比 --jobs 1 约有 4 倍的提升,这个量级可以作为在自己 Runner 上设定该值的参考,同时要记得 AVIF 和 JPEG XL 的编码对 CPU 的消耗明显高于 JPEG 或 HEIC。

常见的坑

第一个坑与输出格式有关。上面的脚本刻意把所有图片,包括 PNG,都转换成 JPEG。对照片而言,JPEG 通常在质量与体积之比上优于 PNG:因此强制 --format jpeg,可以在单纯降低质量还不够的情况下把文件压到阈值以下。代价则是失去透明通道,因为 JPEG 没有 alpha 通道,而且日志中未必会出现任何报错。这个选择对照片有效,但可能悄无声息地毁掉一张透明背景的 PNG logo 或图标。在两种用途混杂的仓库里,与其对所有图片强加单一格式,不如把作业的作用范围收窄(例如限定在某个专用目录)。

在 GitLab CI 中,某个阶段的作业默认只有在上一阶段的所有作业都成功时才会运行。可是 fix-image-weight 只有在 check-image-weight 检测到超标时才有意义,而那恰恰是在默认行为下它永远不会被执行的场景。needs: [] 让修正作业摆脱这一隐式依赖,使其可以独立运行,与校验作业并行。

接着,修正作业会向触发流水线的那个分支推送提交。若不加防范,这次推送可能又触发一条新的合并请求流水线。如果结果仍被判定为过大,作业就再修正一次、再推一个提交、再触发一条流水线。这很容易在几分钟内演变成生成数十个提交和流水线的死循环。在自动提交的信息中加入 [skip ci],就是告诉 GitLab 不要为这个提交触发新的流水线。

针对 CI_MERGE_REQUEST_DIFF_BASE_SHA 执行的 git diff 还有一个前提:这个基准提交在本地是可用的。然而 GitLab 默认以有限深度进行克隆。在一个积累了大量提交的合并请求中,或者当目标分支已经明显分叉时,要找的那个提交可能根本就不在。此时 git diff 会因为一个与图片体积毫无关系的原因而失败。设置 GIT_DEPTH: "0" 会强制完整克隆并规避这个问题,代价是每个作业的克隆时间更长。

另一个坑与变更检测有关。git diff --quiet 只能检测到 Git 已经跟踪的文件的变化。可是一旦脚本把图片转成 JPEG 并用 git rm 删掉原文件,真实的变更其实由两步组成:被跟踪的 .png 的删除,以及一个新的、未被跟踪的 .jpg 的创建。这时普通的 git diff 可能什么都报不出来。实际情况就是,作业可以在日志里显示出完全真实的压缩收益,却依然得出「没有需要提交的内容」的结论,从而永远不推送结果。而同样涵盖未跟踪文件的 git status --porcelain 不会这样被骗过去。

已经存在于 Git 历史中的图片,本身也是一个独立的体积问题。即使在新的提交中把它们换成了压缩版本,旧版本依然作为 Git blob 留在历史里。仓库并不会自动回收这些文件占用的空间。只有改写历史才能清除这些旧数据,而那是一种破坏性操作,在自动化流水线里没有立足之地。

修正模式的自动提交还可能与仓库自身的规则相冲突。受保护分支可能禁止直接推送,或者要求合并前先经过评审。同样,某个钩子或其他格式化机制也可能改动同一批文件,与压缩作业发生冲突。这些相互作用需要明确地测试,而不是想当然地认为它们能相安无事。

最后,这条流水线只覆盖那些经过仓库、更确切地说是经过相应合并请求流程的图片。如果设计师把图片直接放进 CMS、共享目录或 Git 之外的任何素材库,就完全绕开了这项检查。流水线提升的是仓库中受版本控制的图片的质量,但它本身并不构成一套完整的素材管理策略。

衡量收益

fix-image-weight 已经在压缩循环之后,把这份报告输出到了自己的日志里(见上文「与 GitLab CI 集成」一节),可以从合并请求的 Pipelines 标签页查看,无需再添加任何东西。细节在于:--json 输出中的 ratio 字段会与 originalSizeBytescompressedSizeBytes 一起,直接给出每个文件的缩减百分比。

GitLab 中 fix-image-weight 作业的日志,显示压缩收益报告以及每个文件压缩前后的大小
修正作业的日志会打印出每个文件的收益,以及压缩前后的合计值
jq -r '.[] | "\(.path) : \(.originalSizeBytes) -> \(.compressedSizeBytes) 字节 (\(.ratio))"' /tmp/report.json

至于汇总合计,具体命令取决于作业是怎么写 /tmp/report.json 的。fix-image-weight 在循环过程中为每个文件写入一个 JSON 数组(>>),因此最终文件包含的是若干个拼接在一起的数组,而不是单独一个。这时需要 -s(slurp)才能整体读取,但它又会把这些数组再包进一个外层数组:如果不先用 add 把它们摊平,map 会直接在这里报错(jq: error: Cannot index array with string)。上面的脚本用的正是这种写法。

# 整批文件压缩前后的合计
# (分多次调用写入的文件:fix-image-weight)
jq -s 'add | map(.originalSizeBytes) | add' /tmp/report.json
jq -s 'add | map(.compressedSizeBytes) | add' /tmp/report.json

如果你是在别处自行生成报告,比如在本地(见「安装 jpgboost-cli」),或者在一次调用中压缩所有文件的 check-image-weight 里(>),那么那里的 /tmp/report.json 本身就已经是单个 JSON 数组,此时 -s 既没有必要也不正确:它会因为相反的原因产生同样的错误,把一个本已完整的数组又包了一层。

# 同样的计算,针对一次调用写成的报告
# (本地测试,或 check-image-weight)
jq 'map(.originalSizeBytes) | add' /tmp/report.json
jq 'map(.compressedSizeBytes) | add' /tmp/report.json

jpgboost-cli 只测量它自己产出的东西:压缩前后的体积。性能得分(LCP、Lighthouse 分数)仍然需要用专门的工具另行获取。这个工具本身并不原生计算这些指标,而把它们与压缩百分比人为挂钩,只会是一种未经验证的外推。