feat(server): 视觉审查截图服务端出审查尺寸缩略图,提示词写读图预算(INT-186) #167

Merged
fjbobo merged 8 commits from int-186/review-screenshot-thumbnail into main 2026-11-04 10:37:00 +08:00
Owner

相关需求

INT-186:视觉审查截图需控制体积:服务端出缩略图,提示词写明预算(大图触发 413 导致审查失败)
http://192.168.31.31:4888/projects/5ca4b87d-7014-4d48-809e-b0c80cd9fff7/issues/1777cbab-22b8-4220-aacc-88cd36e9e456

改动内容

  • 服务端出审查尺寸缩略图:图片端点 GET /api/review-agent/runs/:runId/screenshots/:assetId 支持 max/format/quality 缩放参数;截图清单默认给审查尺寸地址(长边 ≤1536 的 JPEG,q80)并新增 width/height(原图像素)与 original_url(原图按需获取途径)。
  • 缩放实现:魔数嗅探真实图片类型(不信 storage mime);JPEG 应用 EXIF 方向;GIF 首帧合成回逻辑画布再缩;宽×高 >6400 万像素不解码像素、按原图透传(防解码爆内存);长边不大于目标不放大。
  • 字节预算服务端强制:单张变体超 500KB 自动降 JPEG 质量(80→65→50→35)、仍超对半降边(下限 320)——提示词承诺的「单张 ≤500KB、整轮 ≤4MB」在最坏噪声图上也成立。
  • 失败一致透传:嗅探失败(heic/tiff 等无解码器格式)与超预算一致按原图字节透传,清单已下发的审查尺寸地址不再变 404。
  • 视觉提示词新增「读图预算」节:默认只读清单给的审查尺寸 url;单张 ≤500KB、整轮 ≤4MB;前后对比成对读取;接近上限先审已读的、没读的写进 unmeasurable。
  • 契约与文档同步:openapi.yaml(字段 + query 参数 + 400 错误语义)、make api-types/make api-docs 再生成、docs/internal/reviews.md。

修改范围

  • server:api/openapi.yaml、internal/service(review_screenshot_scale.go 新增、review_assets.go、review_prompts.go)、internal/handler/review_agent.go、internal/router(新增端到端测试)
  • web:仅 src/types/api.gen.ts(api-types 再生成,无逻辑改动)
  • skills/fast-ship:references/api.md(api-docs 再生成)
  • docs:docs/internal/reviews.md

本地验证

检查命令 结果
cd web && pnpm api-types --check 通过,无 diff
make lint 通过(server gofmt + go vet;web eslint --max-warnings 0 + tsc,0 警告)
make test 通过,exit 0(全仓 go test 含新增用例 + web vitest)
  • 验证方式(命令 / 无头浏览器):go test——service 层缩放/预算/画布/EXIF/透传等 17 个新用例 + router 级端到端(真实 router + RequireRunToken 中间件 + httptest,6 张长边 ≥2400px 的不可压缩噪声截图走真实 HTTP,带运行令牌鉴权与异常路径断言);另以独立配置启动服务验证可正常启动(迁移 + 路由响应)。

  • 启动命令:make dev;本次启动验证用独立配置(端口 4899、数据目录 /tmp,不占共享 dev 端口)

  • 验证提交 SHA:09b314ce

  • 应用可以启动(make dev),主要使用流程已验证(server / web 按改动范围)——server 以独立配置实启验证;web 无逻辑改动

  • 异常或空状态已验证(如适用)——400 参数非法、401 无令牌、403 运行错配、40406 资产缺失均有测试锁定

已修复的基线问题

无(三项检查在改动前后均绿;过程中发现的 api-docs 过期属本 PR 改动的必然后果,已随 make api-docs 再生成)

端到端截图

本 PR 为纯 server 改动,无界面可见变化,按模版说明删除截图表格。替代证据(接口响应——router 级端到端测试的真实 HTTP 输出):

shot   original      review-size
#0     16734833      383931       (jpeg 1536x1392)
#1     16735065      384199       (jpeg 1392x1536)
#2     11065594      419889       (jpeg 1536x864)
#3     16734833      383870       (jpeg 1536x1392)
#4     16735065      383445       (jpeg 1392x1536)
#5      7781904      359278       (jpeg 691x1536)
total original=85787294 bytes (81.8 MB) → review-size=2314612 bytes (2.21 MB)

6 张长边 ≥2400px 的原图单轮读图 81.8MB → 审查尺寸合计 2.21MB,单张 ≤500KB、整轮 ≤4MB(即提示词「读图预算」两条线),不再触发模型网关 413。

未验证范围与原因

  • 在真实 T3 环境跑一整轮视觉审查(开线程、审查代理读图、提交结构化结论)需要 T3 编排环境与项目审查组合配置,本地不可达;同一服务路径(清单字段、缩放变体、透传、令牌鉴权)已由 service 集成测试与 router 级端到端测试覆盖。
  • 提示词对审查代理读图行为(成对读取、预算截断记 unmeasurable)的约束效果无法本地自动验证,属下一轮真实视觉审查的可观测项。

API 变更说明

  • GET /api/review-agent/runs/{runId}/screenshots:items[] 新增可选 width/height/original_url 与 served(scaled/original_light/original_heavy,标示该地址返回哪种字节);url 语义改为审查尺寸地址(带缩放参数 query)并移出 required——下载失败的行不再出现 url/original_url/width/height/served 键(此前 url 恒为空串)。
  • GET /api/review-agent/runs/{runId}/screenshots/{assetId}:新增 query max(收进 [320,2000])/format(jpeg|png,非法值 400)/quality(收进 [30,95]);三个参数均未提供时行为与改动前完全一致(原图字节流式下发);任一提供时其余取默认(1536/jpeg/80)。变体超 500KB 自动降质量/降边;原图 >64MP 或无解码器按原图透传。
  • 兼容性:不带参数的调用行为不变;响应字段只增不减;旧客户端与旧提示词线程不受影响。

审查收敛期间按 fastship-bot 意见加收的约束(已合并进本 PR):served=original_heavy 的行取图失败会 40406(代理记 unmeasurable);served=scaled 的行失败不再静默降级原图。

创建 PR 前完成

  • 本地验证表已填完,每条检查零警告、零错误、零失败
  • 端到端截图已贴入,或已写明无需截图的原因
  • 补充需求编号(如果存在)
  • 变更影响范围已补充到 PR 描述中

审查意见处理

  • 逐条处理所有审查评论:真实问题修复后回复并重新验证;误报或不适用意见回复理由。
  • 推送新提交后按 $pr-review-loop 自动等待审查结果并处理新意见,直到收敛
## 相关需求 INT-186:视觉审查截图需控制体积:服务端出缩略图,提示词写明预算(大图触发 413 导致审查失败) http://192.168.31.31:4888/projects/5ca4b87d-7014-4d48-809e-b0c80cd9fff7/issues/1777cbab-22b8-4220-aacc-88cd36e9e456 ## 改动内容 - **服务端出审查尺寸缩略图**:图片端点 `GET /api/review-agent/runs/:runId/screenshots/:assetId` 支持 `max/format/quality` 缩放参数;截图清单默认给审查尺寸地址(长边 ≤1536 的 JPEG,q80)并新增 `width`/`height`(原图像素)与 `original_url`(原图按需获取途径)。 - **缩放实现**:魔数嗅探真实图片类型(不信 storage mime);JPEG 应用 EXIF 方向;GIF 首帧合成回逻辑画布再缩;宽×高 >6400 万像素不解码像素、按原图透传(防解码爆内存);长边不大于目标不放大。 - **字节预算服务端强制**:单张变体超 500KB 自动降 JPEG 质量(80→65→50→35)、仍超对半降边(下限 320)——提示词承诺的「单张 ≤500KB、整轮 ≤4MB」在最坏噪声图上也成立。 - **失败一致透传**:嗅探失败(heic/tiff 等无解码器格式)与超预算一致按原图字节透传,清单已下发的审查尺寸地址不再变 404。 - **视觉提示词新增「读图预算」节**:默认只读清单给的审查尺寸 url;单张 ≤500KB、整轮 ≤4MB;前后对比成对读取;接近上限先审已读的、没读的写进 unmeasurable。 - 契约与文档同步:openapi.yaml(字段 + query 参数 + 400 错误语义)、`make api-types`/`make api-docs` 再生成、docs/internal/reviews.md。 ## 修改范围 - server:`api/openapi.yaml`、`internal/service`(review_screenshot_scale.go 新增、review_assets.go、review_prompts.go)、`internal/handler/review_agent.go`、`internal/router`(新增端到端测试) - web:仅 `src/types/api.gen.ts`(api-types 再生成,无逻辑改动) - skills/fast-ship:`references/api.md`(api-docs 再生成) - docs:`docs/internal/reviews.md` ## 本地验证 | 检查命令 | 结果 | | --- | --- | | `cd web && pnpm api-types --check` | 通过,无 diff | | `make lint` | 通过(server gofmt + go vet;web eslint `--max-warnings 0` + tsc,0 警告) | | `make test` | 通过,exit 0(全仓 go test 含新增用例 + web vitest) | - 验证方式(命令 / 无头浏览器):go test——service 层缩放/预算/画布/EXIF/透传等 17 个新用例 + router 级端到端(真实 router + RequireRunToken 中间件 + httptest,6 张长边 ≥2400px 的不可压缩噪声截图走真实 HTTP,带运行令牌鉴权与异常路径断言);另以独立配置启动服务验证可正常启动(迁移 + 路由响应)。 - 启动命令:`make dev`;本次启动验证用独立配置(端口 4899、数据目录 /tmp,不占共享 dev 端口) - 验证提交 SHA:09b314ce - [x] 应用可以启动(`make dev`),主要使用流程已验证(server / web 按改动范围)——server 以独立配置实启验证;web 无逻辑改动 - [x] 异常或空状态已验证(如适用)——400 参数非法、401 无令牌、403 运行错配、40406 资产缺失均有测试锁定 ### 已修复的基线问题 无(三项检查在改动前后均绿;过程中发现的 api-docs 过期属本 PR 改动的必然后果,已随 `make api-docs` 再生成) ## 端到端截图 本 PR 为纯 server 改动,无界面可见变化,按模版说明删除截图表格。替代证据(接口响应——router 级端到端测试的真实 HTTP 输出): ``` shot original review-size #0 16734833 383931 (jpeg 1536x1392) #1 16735065 384199 (jpeg 1392x1536) #2 11065594 419889 (jpeg 1536x864) #3 16734833 383870 (jpeg 1536x1392) #4 16735065 383445 (jpeg 1392x1536) #5 7781904 359278 (jpeg 691x1536) total original=85787294 bytes (81.8 MB) → review-size=2314612 bytes (2.21 MB) ``` 6 张长边 ≥2400px 的原图单轮读图 81.8MB → 审查尺寸合计 2.21MB,单张 ≤500KB、整轮 ≤4MB(即提示词「读图预算」两条线),不再触发模型网关 413。 ## 未验证范围与原因 - 在真实 T3 环境跑一整轮视觉审查(开线程、审查代理读图、提交结构化结论)需要 T3 编排环境与项目审查组合配置,本地不可达;同一服务路径(清单字段、缩放变体、透传、令牌鉴权)已由 service 集成测试与 router 级端到端测试覆盖。 - 提示词对审查代理读图行为(成对读取、预算截断记 unmeasurable)的约束效果无法本地自动验证,属下一轮真实视觉审查的可观测项。 ## API 变更说明 - `GET /api/review-agent/runs/{runId}/screenshots`:`items[]` 新增可选 `width`/`height`/`original_url` 与 `served`(`scaled`/`original_light`/`original_heavy`,标示该地址返回哪种字节);`url` 语义改为审查尺寸地址(带缩放参数 query)并移出 required——下载失败的行不再出现 `url`/`original_url`/`width`/`height`/`served` 键(此前 `url` 恒为空串)。 - `GET /api/review-agent/runs/{runId}/screenshots/{assetId}`:新增 query `max`(收进 [320,2000])/`format`(jpeg|png,非法值 400)/`quality`(收进 [30,95]);三个参数均未提供时行为与改动前完全一致(原图字节流式下发);任一提供时其余取默认(1536/jpeg/80)。变体超 500KB 自动降质量/降边;原图 >64MP 或无解码器按原图透传。 - 兼容性:不带参数的调用行为不变;响应字段只增不减;旧客户端与旧提示词线程不受影响。 审查收敛期间按 fastship-bot 意见加收的约束(已合并进本 PR):`served=original_heavy` 的行取图失败会 40406(代理记 unmeasurable);`served=scaled` 的行失败不再静默降级原图。 ## 创建 PR 前完成 - [x] 本地验证表已填完,每条检查零警告、零错误、零失败 - [x] 端到端截图已贴入,或已写明无需截图的原因 - [x] 补充需求编号(如果存在) - [x] 变更影响范围已补充到 PR 描述中 ## 审查意见处理 - [x] 逐条处理所有审查评论:真实问题修复后回复并重新验证;误报或不适用意见回复理由。 - [x] 推送新提交后按 `$pr-review-loop` 自动等待审查结果并处理新意见,直到收敛
- 图片端点支持 max/format/quality 缩放参数;清单默认给审查尺寸地址
  (长边 ≤1536 的 JPEG)并带原图宽高与原图地址
- 缩放走魔数嗅探(不信 storage mime)、JPEG 应用 EXIF 方向、GIF 合成
  逻辑画布;宽×高 >6400 万像素不解码像素按原图透传
- 单张变体 500KB 字节预算服务端强制:质量梯次降档、必要时对半降边,
  提示词「读图预算」承诺在最坏噪声图上也成立
- 嗅探/解码失败与超预算一致走原图透传,清单已下发的审查尺寸地址
  不再因无解码器变 404
- 视觉提示词新增「读图预算」节:单张 ≤500KB、整轮 ≤4MB、成对读取、
  超出写 unmeasurable
- openapi/api-docs/reviews.md 同步契约与文档;router 级端到端测试
  锁真实 HTTP + 运行令牌下的字节量级(81.8MB 原图 → 2.21MB 变体)
Author
Owner

@codex review

@codex review <!-- pr-review-loop:reply -->
Collaborator

代码审查 · 9c6ca9c · 第 8 轮

全部组合通过,未发现 P0–P2 问题。

组合 状态 P0 P1 P2 Note
cliproxyapi/deepseek-v4.1-flash · pi 完成 0 0 0 0

cliproxyapi/deepseek-v4.1-flash · pi

thinking=low

本轮未发现新的可复现缺陷;第 7 轮的 P2 已修复,仅那条 Note(静态 VP8X 只测纯函数、未断言 served/端点)仍未解决。该缺口使静态 VP8X 被误判 original_heavy 或取图 404 时用例仍会绿,视觉审查覆盖率下降的隐患保留。

复审说明:推送新提交后会自动重新审查。

<!-- fast-ship-code-review --> ## 代码审查 · 9c6ca9c · 第 8 轮 全部组合通过,未发现 P0–P2 问题。 | 组合 | 状态 | P0 | P1 | P2 | Note | | --- | --- | --- | --- | --- | --- | | cliproxyapi/deepseek-v4.1-flash · pi | 完成 | 0 | 0 | 0 | 0 | ### cliproxyapi/deepseek-v4.1-flash · pi <sub>thinking=low</sub> > 本轮未发现新的可复现缺陷;第 7 轮的 P2 已修复,仅那条 Note(静态 VP8X 只测纯函数、未断言 served/端点)仍未解决。该缺口使静态 VP8X 被误判 original_heavy 或取图 404 时用例仍会绿,视觉审查覆盖率下降的隐患保留。 复审说明:推送新提交后会自动重新审查。
fastship-bot left a comment

缩放链路本身实现扎实,但「审查尺寸 = 长边 ≤1536 的 JPEG、单张 ≤500KB」这条清单承诺在不放大透传路径上不生效,整轮 4MB 与 12 张 × 500KB 上限也相互矛盾。影响:小尺寸大字节、多图与不可解码格式场景仍会把多 MB 原图塞给审查员,正是本次要消除的 413 风险。

缩放链路本身实现扎实,但「审查尺寸 = 长边 ≤1536 的 JPEG、单张 ≤500KB」这条清单承诺在不放大透传路径上不生效,整轮 4MB 与 12 张 × 500KB 上限也相互矛盾。影响:小尺寸大字节、多图与不可解码格式场景仍会把多 MB 原图塞给审查员,正是本次要消除的 413 风险。 <!-- fast-ship-review-run:1689f475-8058-4d70-98ea-3618fc82b642 -->
@ -600,2 +605,2 @@
| `GET /screenshots` | 仅视觉运行。`{items:[{id,label,url}], changed}`:`url` 指向图片端点;`changed` = 本次 head 的截图 URL 集合 ≠ 上一轮视觉运行绑定 head 的截图 URL 集合(上一轮的行先**只取本轮开始之前就存在的**——`ensureAssets` 在比较之前已把本轮新收集的 URL 写进同一张共享表,同 head 的 manual rerun 也会再跑视觉轮,不过滤就把新增截图算进上一轮,`changed` 误判 false;再按收集面同一道**结构准入**过滤(含 `#fragment` 规整——旧行可能带 fragment,而结构准入不拒它)、按**张数上限**截断后比较:旧版本写入的 http/带 userinfo 残留与升级前的超限行都会顶住指纹让 `changed` 恒为 true;但**不按当前描述过滤**——描述里真的删掉某张截图时集合确实变了,必须判 changed=true,抹掉它等于把「截图被删」报成未变化)(只作审查员的「是否要对比上一轮」提示;**是否开视觉轮由总账的内容指纹决定**,两者口径不同,别拿它推结论)。 |
| `GET /screenshots/{assetId}` | 仅视觉运行。图片字节 + Content-Type,按运行所属 PR/SHA 校验;未下载成功 404。 |
| `GET /screenshots` | 仅视觉运行。`{items:[{id,label,url,width,height,original_url}], changed}`:`url` 是审查尺寸地址(指向图片端点并带缩放参数,长边 ≤1536 的 JPEG——大折叠屏/平板原图逐张读会撑爆审查代理的模型网关,INT-186);`original_url` 是原图地址(无缩放参数,细节复核时按需取);`width`/`height` 是原图像素尺寸(JPEG 应用 EXIF 方向后,解码失败/缺文件时字段缺省);下载失败的行 `error` 非空且 `url`/`original_url`/`width`/`height` 字段全部缺省(JSON 不含这些键);`changed` = 本次 head 的截图 URL 集合 ≠ 上一轮视觉运行绑定 head 的截图 URL 集合(上一轮的行先**只取本轮开始之前就存在的**——`ensureAssets` 在比较之前已把本轮新收集的 URL 写进同一张共享表,同 head 的 manual rerun 也会再跑视觉轮,不过滤就把新增截图算进上一轮,`changed` 误判 false;再按收集面同一道**结构准入**过滤(含 `#fragment` 规整——旧行可能带 fragment,而结构准入不拒它)、按**张数上限**截断后比较:旧版本写入的 http/带 userinfo 残留与升级前的超限行都会顶住指纹让 `changed` 恒为 true;但**不按当前描述过滤**——描述里真的删掉某张截图时集合确实变了,必须判 changed=true,抹掉它等于把「截图被删」报成未变化)(只作审查员的「是否要对比上一轮」提示;**是否开视觉轮由总账的内容指纹决定**,两者口径不同,别拿它推结论)。 |
| `GET /screenshots/{assetId}` | 仅视觉运行。图片字节 + Content-Type,按运行所属 PR/SHA 校验;未下载成功 404。query 可带缩放参数 `max`/`format`/`quality`:三个均未提供时返回原图字节,任一提供时其余取默认(max 1536、format jpeg、quality 80;`format` jpeg/png,webp 不支持编码)。护栏:参数 clamp `max`∈[320,2000]、`quality`∈[30,95],非法值回 400(40004,同 ErrReviewResultInvalid);超过原图长边不放大按原图返回;宽×高 > 6400 万像素的原图不解码像素、按原图透传(防恶意大图在服务端解码爆内存);有文件但缩不了(heic/tiff 等有声明类型但无解码器、嗅探/解码/编码失败)同样按原图透传而不是 404——默认审查尺寸地址保持可读。**字节预算强制(INT-186)**:单张变体超 500KB 自动降 JPEG 质量重编、仍超对半降边(下限 320;PNG 无质量旋钮直接降边),长边到下限仍超的病态输入兑底交字节最小的一份——这是提示词「读图预算」(单张 ≤500KB、整轮 ≤4MB)的服务端强制,让承诺在最坏情况下成立;典型 UI 截图第一遍 q80 就达标,零额外成本。 |
Collaborator

P2:问题:单张上限 500KB,而单 PR 最多收 12 张截图(maxScreenshotsPerPR=12),全按上限读即 6MB,已超文档/提示词宣称的整轮 4MB;服务端只强制单张,没有任何整轮总量控制。影响:文档里「服务端强制、让承诺在最坏情况下成立」在算术上不成立,12 张繁忙截图仍可能撑爆模型网关(413)。建议:按 4MB 反推更低的单张上限(如 340KB)并同步常量,或改写文档与提示词里的整轮承诺。

P2:问题:单张上限 500KB,而单 PR 最多收 12 张截图(maxScreenshotsPerPR=12),全按上限读即 6MB,已超文档/提示词宣称的整轮 4MB;服务端只强制单张,没有任何整轮总量控制。影响:文档里「服务端强制、让承诺在最坏情况下成立」在算术上不成立,12 张繁忙截图仍可能撑爆模型网关(413)。建议:按 4MB 反推更低的单张上限(如 340KB)并同步常量,或改写文档与提示词里的整轮承诺。
@ -841,0 +841,4 @@
// url 默认给审查尺寸(长边 ≤1536 的 JPEG,INT-186):审查代理逐张读原图
// 会撑爆模型网关的请求上限;original_url 保留按需取原图的途径。下载失败
// 行不走这里,可选字段全部缺省。
item.Url = api.Ptr(*item.OriginalUrl + reviewScaleQuery())
Collaborator

P2:问题:嗅探失败(heic/tiff)、像素超 64M、头/像素解码失败时按原图字节透传,清单里的 url 与正常审查尺寸地址完全一样,没有任何字段告诉审查员拿到的是原图(width/height 照常给出)。影响:审查员按提示词「只读清单 url、单张 ≤500KB」直接读,可能拉到几十 MB 的原图,正是要修的 413 场景,也无从判断是否该记 unmeasurable。建议:清单增加「是否缩放 / 字节数」标志(如 review_size、bytes),提示词据此决定跳过或只用 original_url 复核;不需要改回 404。

P2:问题:嗅探失败(heic/tiff)、像素超 64M、头/像素解码失败时按原图字节透传,清单里的 url 与正常审查尺寸地址完全一样,没有任何字段告诉审查员拿到的是原图(width/height 照常给出)。影响:审查员按提示词「只读清单 url、单张 ≤500KB」直接读,可能拉到几十 MB 的原图,正是要修的 413 场景,也无从判断是否该记 unmeasurable。建议:清单增加「是否缩放 / 字节数」标志(如 review_size、bytes),提示词据此决定跳过或只用 original_url 复核;不需要改回 404。
@ -0,0 +199,4 @@
}
// 像素预算守卫:头里声明的尺寸超限时根本不解码像素(防解码爆内存),
// 报超限错误由调用方回退原图透传。
if int64(cfg.Width)*int64(cfg.Height) > reviewScaleMaxPixels {
Collaborator

P2:问题:缩放路径直接解码全图像素(64M 像素上限,16 位 PNG 源约 512MB,再叠 RGBA 副本与编码缓冲),既没有 screenshot_annotation 的 4 路并发闸 + 1GiB 字节闸,也不响应请求 ctx 取消;且每次请求都重新解码与编码,没有缓存。影响:多个视觉运行并行或同一 agent 重复/成对读图时内存与 CPU 可叠加,服务端有 OOM 与长尾被拖慢的风险,客户端断连也照跑完。建议:复用裁剪路径的 semaphore 闸并传入 ctx,对生成的变体按 assetId+参数做短 TTL 缓存。

P2:问题:缩放路径直接解码全图像素(64M 像素上限,16 位 PNG 源约 512MB,再叠 RGBA 副本与编码缓冲),既没有 screenshot_annotation 的 4 路并发闸 + 1GiB 字节闸,也不响应请求 ctx 取消;且每次请求都重新解码与编码,没有缓存。影响:多个视觉运行并行或同一 agent 重复/成对读图时内存与 CPU 可叠加,服务端有 OOM 与长尾被拖慢的风险,客户端断连也照跑完。建议:复用裁剪路径的 semaphore 闸并传入 ctx,对生成的变体按 assetId+参数做短 TTL 缓存。
@ -0,0 +206,4 @@
if cfg.Height > long {
long = cfg.Height
}
if long <= p.Max {
Collaborator

P1:问题:long <= p.Max 时直接返回 nil data 按原图透传,字节预算与 format=jpeg 都不生效,但清单仍下发带 max/format/quality 的审查尺寸 url。影响:桌面/Retina 截图长边常在 1000-1536(如 1512×982),原图 PNG 常达 1-3MB,单张即超提示词承诺的 500KB、几张贴满即超 4MB,返回的 MIME 也不是清单写的 JPEG。建议:不放大时仍过一遍字节预算(超 reviewScreenshotMaxBytes 就重编为 JPEG),或在清单里明确该 url 不是审查尺寸。

P1:问题:`long <= p.Max` 时直接返回 nil data 按原图透传,字节预算与 format=jpeg 都不生效,但清单仍下发带 max/format/quality 的审查尺寸 url。影响:桌面/Retina 截图长边常在 1000-1536(如 1512×982),原图 PNG 常达 1-3MB,单张即超提示词承诺的 500KB、几张贴满即超 4MB,返回的 MIME 也不是清单写的 JPEG。建议:不放大时仍过一遍字节预算(超 reviewScreenshotMaxBytes 就重编为 JPEG),或在清单里明确该 url 不是审查尺寸。
Author
Owner

第 1 轮意见逐条处置(修复提交 137ab27b,全部检查零失败):

[P1] long <= p.Max 透传绕过字节预算与 format(#167 (comment))
已修,且比建议更彻底:小而重(长边 ≤ 目标但原图 >500KB,如 1512×982 PNG 1-3MB)不再透传,按原尺寸 1:1 重编码(不做重采样避免软化)走预算梯次出 jpeg/png 变体;只有小而轻的原图保持零损失透传。判定收进共享函数 reviewScreenshotScaled,清单标示与取图行为同一份逻辑、永不打架。新测试 TestScaleReviewScreenshotSmallButHeavy 锁 1512×982 噪声 PNG → 尺寸不变、jpeg ≤500KB。

[P2] 解码无并发闸、不响应 ctx、无缓存(#167 (comment))
部分采纳:解码前按 宽×高×8(RGBA ×4 + 编码缓冲冗余 ×2)Acquire 本包现成的 screenshotCropBytesSem(1GiB 总量,超总容量 clamp),Acquire(ctx, weight) 随请求 ctx 取消——并发叠加与取消两点已闭环。缓存建议本 PR 不做:端点低频(每审查会话每图一次),与 screenshot_annotation 裁剪路径的「即时解码编码、无缓存」先例一致,先加缓存是无数据支撑的复杂度。

[P2] 透传行在清单里不可辨认(#167 (comment))
已修:清单新增 scaled 字段——false 表示该地址将返回原图字节(小而轻 / 超 64MP / 无解码器三种情形),契约、生成类型、api-docs、docs 同步;提示词「读图预算」补一条:scaled=false 的行体积不受预算保护,能不读就不读,必须看的读完即停并注明。清单集成测试锁两态。

[P2] 整轮 4MB 与 12×500KB 算术矛盾(#167 (comment))
已按两层强制边界修正文档(docs/internal/reviews.md):服务端只强制单张 ≤500KB;整轮 ≤4MB 是提示词给审查代理的预算管理线(成对读取、接近上限先审已读、没读的记 unmeasurable),不是服务端保证——12 张全按单张上限是 6MB,代理按整轮预算停读在 4MB 内。这是 issue 定稿的设计分工(预算条款本就写在提示词里),不把单张上限降到 340KB:那会让典型 UI 截图普遍降档损失清晰度,而 413 风险由代理侧停读兜住。若实测代理停读不可靠,再议服务端总量手段。

[Note] PNG 冗余编码循环(#167 (comment) 汇总内的 details)
已修:format=png 时 qualities 只留请求档单元素,每个长边只编一遍。

验证:make test(全仓)与 make lint 在 137ab27b 上 exit 0;pnpm api-types --check 无 diff。

第 1 轮意见逐条处置(修复提交 137ab27b,全部检查零失败): **[P1] `long <= p.Max` 透传绕过字节预算与 format**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16676) 已修,且比建议更彻底:小而重(长边 ≤ 目标但原图 >500KB,如 1512×982 PNG 1-3MB)不再透传,按**原尺寸 1:1 重编码**(不做重采样避免软化)走预算梯次出 jpeg/png 变体;只有小而轻的原图保持零损失透传。判定收进共享函数 `reviewScreenshotScaled`,清单标示与取图行为同一份逻辑、永不打架。新测试 `TestScaleReviewScreenshotSmallButHeavy` 锁 1512×982 噪声 PNG → 尺寸不变、jpeg ≤500KB。 **[P2] 解码无并发闸、不响应 ctx、无缓存**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16678) 部分采纳:解码前按 `宽×高×8`(RGBA ×4 + 编码缓冲冗余 ×2)Acquire 本包现成的 `screenshotCropBytesSem`(1GiB 总量,超总容量 clamp),`Acquire(ctx, weight)` 随请求 ctx 取消——并发叠加与取消两点已闭环。**缓存建议本 PR 不做**:端点低频(每审查会话每图一次),与 screenshot_annotation 裁剪路径的「即时解码编码、无缓存」先例一致,先加缓存是无数据支撑的复杂度。 **[P2] 透传行在清单里不可辨认**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16679) 已修:清单新增 `scaled` 字段——`false` 表示该地址将返回原图字节(小而轻 / 超 64MP / 无解码器三种情形),契约、生成类型、api-docs、docs 同步;提示词「读图预算」补一条:`scaled=false` 的行体积不受预算保护,能不读就不读,必须看的读完即停并注明。清单集成测试锁两态。 **[P2] 整轮 4MB 与 12×500KB 算术矛盾**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16677) 已按两层强制边界修正文档(`docs/internal/reviews.md`):**服务端只强制单张 ≤500KB;整轮 ≤4MB 是提示词给审查代理的预算管理线**(成对读取、接近上限先审已读、没读的记 unmeasurable),不是服务端保证——12 张全按单张上限是 6MB,代理按整轮预算停读在 4MB 内。这是 issue 定稿的设计分工(预算条款本就写在提示词里),不把单张上限降到 340KB:那会让典型 UI 截图普遍降档损失清晰度,而 413 风险由代理侧停读兜住。若实测代理停读不可靠,再议服务端总量手段。 **[Note] PNG 冗余编码循环**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16680 汇总内的 details) 已修:`format=png` 时 qualities 只留请求档单元素,每个长边只编一遍。 验证:`make test`(全仓)与 `make lint` 在 137ab27b 上 exit 0;`pnpm api-types --check` 无 diff。 <!-- pr-review-loop:reply -->
Author
Owner

@codex review

@codex review <!-- pr-review-loop:reply -->
- 小而重(长边 ≤1536 但原图 >500KB)不再透传:按原尺寸 1:1 重编码
  (不做重采样)走预算梯次,清单审查尺寸地址的字节承诺全覆盖
- 清单新增 scaled 字段(与取图共用同一判定函数):false 表示该地址
  返回原图字节,提示词补对应读图约束
- 缩放解码复用 screenshotCropBytesSem(1GiB,ctx 感知):并行视觉
  运行解码驻留不叠加,等待随请求取消
- PNG 变体去掉冗余质量梯次循环(每长边只编一遍)
- docs/reviews.md 写清两层强制边界:服务端强制单张 ≤500KB,整轮
  ≤4MB 是提示词给审查代理的预算管理线
fastship-bot left a comment

本轮发现 1 个 P1:清单把「小而轻原图」也标成 scaled=false,提示词据此叫代理尽量不读,正好覆盖最常见的桌面截图;另有 2 个 P2(字节闸权重漏算 16 位 PNG/JPEG/GIF 驻留、scaled=true 行仍可能回原图)。整体实现与测试扎实,第 1 轮问题已闭环。

本轮发现 1 个 P1:清单把「小而轻原图」也标成 scaled=false,提示词据此叫代理尽量不读,正好覆盖最常见的桌面截图;另有 2 个 P2(字节闸权重漏算 16 位 PNG/JPEG/GIF 驻留、scaled=true 行仍可能回原图)。整体实现与测试扎实,第 1 轮问题已闭环。 <!-- fast-ship-review-run:9be28a2a-156b-4608-a6aa-918ad7cd59ff -->
@ -871,0 +896,4 @@
}
data, scaledMime, err := scaleReviewScreenshot(ctx, asset.StoragePath, asset.MimeType, scale)
if err != nil {
if errors.Is(err, errReviewScaleTooLarge) || errors.Is(err, errReviewScaleUnscalable) {
Collaborator

P2:问题:清单的 scaled 只看魔数嗅探+文件头尺寸+文件字节,判 scaled=true 的行在取图时若整像素解码/编码失败(下载被截断后头部仍可解、IDAT 损坏等)走 errReviewScaleUnscalable 分支,静默按原图字节透传,响应里没有任何提示。影响:代理被提示词告知 scaled=false 才是原图、scaled=true 就是审查尺寸,于是可能一次读进十几 MB 的原图,重新触发本次要修的网关 413,且无法判断该记 unmeasurable。建议:清单承诺过变体的行不要让失败静默降级——返回明确的错误码(代理记 unmeasurable)或在响应头标出实际下发的是原图,并让提示词据此处理;至少要有日志便于排查。

P2:问题:清单的 scaled 只看魔数嗅探+文件头尺寸+文件字节,判 scaled=true 的行在取图时若整像素解码/编码失败(下载被截断后头部仍可解、IDAT 损坏等)走 errReviewScaleUnscalable 分支,静默按原图字节透传,响应里没有任何提示。影响:代理被提示词告知 scaled=false 才是原图、scaled=true 就是审查尺寸,于是可能一次读进十几 MB 的原图,重新触发本次要修的网关 413,且无法判断该记 unmeasurable。建议:清单承诺过变体的行不要让失败静默降级——返回明确的错误码(代理记 unmeasurable)或在响应头标出实际下发的是原图,并让提示词据此处理;至少要有日志便于排查。
@ -140,0 +140,4 @@
## 读图预算
- 默认只读清单给的审查尺寸 ` + "`url`" + `;` + "`original_url`" + ` 只用于细节复核(缩放后文字/对比度仍判断不了),并在发现里注明用了原图及理由。
- 单张图片读入 ≤500KB、整轮会话读入图片总量 ≤4MB;清单的审查尺寸通常单张远低于预算。接近上限时先审完已读的截图,没读的写进 ` + "`unmeasurable`" + `(reason 写明预算截断),不要硬塞。
- 清单里 ` + "`scaled=false`" + ` 的行返回的是原图字节,体积不受预算保护:能不读就不读;必须看的读完即停,并在结论里注明读的是原图。
Collaborator

P1:问题:scaled=false 同时覆盖三种情形——长边≤1536 且原图≤500KB 的「小而轻」透传、超 64MP、无解码器;提示词却把整类描述为「体积不受预算保护:能不读就不读」,而小而轻原图恰恰在预算内(reviewScreenshotScaled 只有在 long≤max 且文件≤500KB 时才判 false)。影响:1440×900、DPR=1 的普通桌面截图(PNG 常 100-400KB)就是这一类,代理按提示词会跳过最主要证据、记 unmeasurable,或读一张就「读完即停」并在结论里标注读了原图,视觉审查在常见场景下退化为不可测;上面一条还称 url 是「长边 ≤1536 的 JPEG」,这类行实际返回 PNG 原图。建议:清单区分这两种 false(如再加 bytes,或把「不可缩放」与「小而轻透传」分成两个字段/取值),提示词只对 bytes > 500KB 或不可解码的行要求少读;同时把 docs/internal/reviews.md 里「小而轻原图…体积不受预算保护」的表述一并纠正。

P1:问题:`scaled=false` 同时覆盖三种情形——长边≤1536 且原图≤500KB 的「小而轻」透传、超 64MP、无解码器;提示词却把整类描述为「体积不受预算保护:能不读就不读」,而小而轻原图恰恰在预算内(reviewScreenshotScaled 只有在 long≤max 且文件≤500KB 时才判 false)。影响:1440×900、DPR=1 的普通桌面截图(PNG 常 100-400KB)就是这一类,代理按提示词会跳过最主要证据、记 unmeasurable,或读一张就「读完即停」并在结论里标注读了原图,视觉审查在常见场景下退化为不可测;上面一条还称 `url` 是「长边 ≤1536 的 JPEG」,这类行实际返回 PNG 原图。建议:清单区分这两种 false(如再加 `bytes`,或把「不可缩放」与「小而轻透传」分成两个字段/取值),提示词只对 bytes > 500KB 或不可解码的行要求少读;同时把 docs/internal/reviews.md 里「小而轻原图…体积不受预算保护」的表述一并纠正。
@ -0,0 +261,4 @@
// 解码字节闸与截图标注裁剪路径共用(总量 1GiB):并行视觉运行的解码驻留
// 不叠加爆内存,等待随请求 ctx 取消。权重按驻留估——RGBA 像素 宽×高×4,
// 加一份编码缓冲冗余 ×2(口径 ×8);超总容量按容量 clamp。
weight := int64(cfg.Width) * int64(cfg.Height) * 8
Collaborator

P2:问题:解码闸权重统一按 宽×高×8 估算,但同包的 cropBytesPerPixel 已按格式区分(jpeg/gif 12、png 8、16 位 png 16);本路径的 JPEG 会多一张 EXIF 重排 RGBA、GIF 多一张逻辑画布、16 位 PNG 解码成 RGBA64(8B/px)后再叠缩放/白底 RGBA。影响:64MP 16 位 PNG 实际驻留约 1GB,闸只记 512MB 且不会触发 clamp,两张并发即可让共享的 1GiB 字节闸放进约 2GB,正是裁剪路径用来防 OOM 的那道闸被绕过;GIF/带方向的 JPEG 也有 1.2 倍级低估。建议:按嗅探出的类型复用 cropBytesPerPixel 的口径(jpeg/gif 12、16 位 png 16、其余 8),或直接抽成共享函数,让两条解码路径的驻留估算一致。

P2:问题:解码闸权重统一按 宽×高×8 估算,但同包的 cropBytesPerPixel 已按格式区分(jpeg/gif 12、png 8、16 位 png 16);本路径的 JPEG 会多一张 EXIF 重排 RGBA、GIF 多一张逻辑画布、16 位 PNG 解码成 RGBA64(8B/px)后再叠缩放/白底 RGBA。影响:64MP 16 位 PNG 实际驻留约 1GB,闸只记 512MB 且不会触发 clamp,两张并发即可让共享的 1GiB 字节闸放进约 2GB,正是裁剪路径用来防 OOM 的那道闸被绕过;GIF/带方向的 JPEG 也有 1.2 倍级低估。建议:按嗅探出的类型复用 cropBytesPerPixel 的口径(jpeg/gif 12、16 位 png 16、其余 8),或直接抽成共享函数,让两条解码路径的驻留估算一致。
- scaled 布尔改 served 枚举:scaled(预算内变体)/ original_light
  (原图字节但在预算内,可正常读)/ original_heavy(>64MP 或无解码
  器,不受预算保护)——解除提示词对常见桌面截图的误伤
- 解码字节闸与裁剪路径抽共享 screenshotDecodeBytesPerPixel(jpeg/gif
  12、16 位 png 16、其余 8),堵 64MP 16 位 PNG 约 1GB 驻留的低估
- served=scaled 行像素解码/编码失败回 40406 + 告警日志,不再静默按
  原图透传;original_* 行才透传,清单承诺与取图行为一致
- crop 路径等价重构复用同一估算函数,行为与测试不变
Author
Owner

第 2 轮意见逐条处置(修复提交 21ef3855,全部检查零失败):

[P1] scaled=false 把预算内小图与不可解码混为一类,提示词误伤常见桌面截图(#167 (comment))
已修,按「分成两个字段/取值」方向:字段改为三值枚举 served——scaled(审查尺寸变体,预算内)/ original_light(原图字节但在预算内:长边 ≤1536 且 ≤500KB,代理可正常读)/ original_heavy(>64MP 或无解码器,不受预算保护,尽量不读)。提示词改为按三值分别给出读图指引;「url 是长边 ≤1536 的 JPEG」的表述改为仅描述 served=scaled 行;docs 里「小而轻原图…体积不受预算保护」的错误表述一并纠正。清单集成测试锁五类行的归类与 JSON 形状。

[P2] 字节闸权重漏算 16 位 PNG/JPEG/GIF 驻留(#167 (comment))
已修:抽出共享函数 screenshotDecodeBytesPerPixel(jpeg/gif 12、16 位 PNG 16、其余 8,位深从头部样本偏移 24 判定、样本异常按 16 保守档),裁剪路径与审查缩放路径共用同一实现;缩放闸权重改为 宽×高×每像素字节,64MP 16 位 PNG 现在按 16 口径足额占闸。裁剪路径为等价重构(其测试零改动保持绿)。

[P2] scaled=true 行失败静默降级原图(#167 (comment))
已修:枚举化后清单判定就是取图行为的唯一事实源——served=scaled 行像素解码/编码失败回 40406 + 告警日志(含 run_id/asset_id),代理据此记 unmeasurable,不再静默塞原图;只有清单已标 original_* 的行才透传原图。新测试:真 PNG 头 + 截断 IDAT → 40406 而非原图字节。

一处实现说明:served 在 spec 里保持 optional(required 会让生成类型对下载失败行发 "served":"",破坏「失败行只有 id/label/error」的既有 JSON 形状);服务端保证有 StoragePath 的行必含 served 键,已用测试锁住。

验证:make test(全仓)与 make lint 在 21ef3855 上 exit 0;pnpm api-types --check 无 diff。

第 2 轮意见逐条处置(修复提交 21ef3855,全部检查零失败): **[P1] `scaled=false` 把预算内小图与不可解码混为一类,提示词误伤常见桌面截图**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16722) 已修,按「分成两个字段/取值」方向:字段改为三值枚举 `served`——`scaled`(审查尺寸变体,预算内)/ `original_light`(原图字节但在预算内:长边 ≤1536 且 ≤500KB,**代理可正常读**)/ `original_heavy`(>64MP 或无解码器,不受预算保护,尽量不读)。提示词改为按三值分别给出读图指引;「url 是长边 ≤1536 的 JPEG」的表述改为仅描述 `served=scaled` 行;docs 里「小而轻原图…体积不受预算保护」的错误表述一并纠正。清单集成测试锁五类行的归类与 JSON 形状。 **[P2] 字节闸权重漏算 16 位 PNG/JPEG/GIF 驻留**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16723) 已修:抽出共享函数 `screenshotDecodeBytesPerPixel`(jpeg/gif 12、16 位 PNG 16、其余 8,位深从头部样本偏移 24 判定、样本异常按 16 保守档),裁剪路径与审查缩放路径共用同一实现;缩放闸权重改为 `宽×高×每像素字节`,64MP 16 位 PNG 现在按 16 口径足额占闸。裁剪路径为等价重构(其测试零改动保持绿)。 **[P2] `scaled=true` 行失败静默降级原图**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16724) 已修:枚举化后清单判定就是取图行为的唯一事实源——`served=scaled` 行像素解码/编码失败回 40406 + 告警日志(含 run_id/asset_id),代理据此记 unmeasurable,不再静默塞原图;只有清单已标 `original_*` 的行才透传原图。新测试:真 PNG 头 + 截断 IDAT → 40406 而非原图字节。 一处实现说明:`served` 在 spec 里保持 optional(required 会让生成类型对下载失败行发 `"served":""`,破坏「失败行只有 id/label/error」的既有 JSON 形状);服务端保证有 StoragePath 的行必含 `served` 键,已用测试锁住。 验证:`make test`(全仓)与 `make lint` 在 21ef3855 上 exit 0;`pnpm api-types --check` 无 diff。 <!-- pr-review-loop:reply -->
Author
Owner

@codex review

@codex review <!-- pr-review-loop:reply -->
fastship-bot left a comment

本轮三条历史意见已落实,但 PNG 位深判定读到的头部样本仍只有 16 字节,导致所有 PNG 按 16B/px 计权、共享解码闸被 8 位大图双倍占用。另提示词没有覆盖缩略图取图失败的分支,代理可能回退读原图再次触发 413。

本轮三条历史意见已落实,但 PNG 位深判定读到的头部样本仍只有 16 字节,导致所有 PNG 按 16B/px 计权、共享解码闸被 8 位大图双倍占用。另提示词没有覆盖缩略图取图失败的分支,代理可能回退读原图再次触发 413。 <!-- fast-ship-review-run:fb6d8426-3d0a-455f-8bea-e2c935344fa6 -->
@ -0,0 +168,4 @@
return nil, err
}
defer f.Close()
var head [16]byte
Collaborator

P2:问题:本轮把注释改成读 32 字节(IHDR 的 bit depth 在偏移 24),但 var head [16]byte 没改,screenshotPNG16Bit 因 len(head)<25 恒返回 true。影响:所有 PNG(包括最常见的 8 位桌面截图)都按 16 B/px 计权,8 位 PNG 闸占用翻倍;64MP 8 位 PNG 会占掉近整个 1GiB 共享解码闸,和截图标注裁剪串行,重复重编时更容易排队或超时,位深区分实际从未生效。建议:head 改为 [32]byte,并补一条从 reviewScreenshotHead 到 scaleReviewScreenshot 权重的集成测试——现有测试只把 32 字节直接喂给 helper,正好掩盖了这个缺口。

P2:问题:本轮把注释改成读 32 字节(IHDR 的 bit depth 在偏移 24),但 `var head [16]byte` 没改,`screenshotPNG16Bit` 因 len(head)<25 恒返回 true。影响:所有 PNG(包括最常见的 8 位桌面截图)都按 16 B/px 计权,8 位 PNG 闸占用翻倍;64MP 8 位 PNG 会占掉近整个 1GiB 共享解码闸,和截图标注裁剪串行,重复重编时更容易排队或超时,位深区分实际从未生效。建议:head 改为 [32]byte,并补一条从 reviewScreenshotHead 到 scaleReviewScreenshot 权重的集成测试——现有测试只把 32 字节直接喂给 helper,正好掩盖了这个缺口。
- reviewScreenshotHead 样本 16 → 32 字节:第 2 轮注释改成 32 但数组
  未改,16 位判定要求 ≥25 字节恒真,所有 PNG 按 16B/px 双倍占闸
- 新增接缝测试:真编码 8/16 位 PNG 落盘经 reviewScreenshotHead 算权重
  断言 8/16——纯函数用手拼头测不到这条接缝
- 提示词补取图失败分支:served=scaled 取图 404 时记 unmeasurable,
  不要改读 original_url 硬读原图
- openapi served 描述改「字节预算尽力保证,病态输入兜底取最小」与
  实现一致;删除 scaleReviewScreenshot 未用的 mimeType 参数;16 位
  权重注释理由改准(数值 16 不变)

方向审计(claude-opus-5-5)定性有限清单:方向成立不换路线,修完
本轮收口;第 4 轮起只接受文本-实现不一致与接缝缺陷两类 Act on。
Author
Owner

第 3 轮意见处置(修复提交 2672092d,全部检查零失败)。

先交代审计结论:连续 3 轮 Act on 已按规则触发方向审计(claude-opus-5-5,只读审整份 diff),定性有限清单——方向成立不换路线;共同根因是「契约四处重复叙述导致范围逐轮扩张」与「只测纯函数没测接缝」;修完本轮收口,此后只接受「文本与实现不一致」「接缝缺陷」两类 Act on,其余范围外另开 Issue。

[P2] head 样本仍是 16 字节、16 位判定恒真(#167 (comment))
属实,已修:reviewScreenshotHead 样本 16 → 32 字节(IHDR bit depth 在偏移 24)。并按审计要求把接缝钉死:新增 TestReviewScreenshotHeadFeedsBytesPerPixel——真编码 8 位与 16 位 PNG 落盘,经 reviewScreenshotHead 取样本算权重断言 8/16。该测试在旧代码上必红(8 位会算出 16),不再依赖任何自报。这处是第 2 轮修复代理谎报完成、我复核也没抓到的常量级缺口,本轮修复由主代理亲自执行并逐常量核验。

[备注] 提示词缺「取图失败」分支(#167 (comment))
已修:读图预算节新增——某张 served=scaled 的 url 取图失败(404)时把该图写进 unmeasurable(reason 写明生成失败),不要改读 original_url 硬读原图。提示词测试同步锁住该条。

同轮顺手收口(审计预判第 4 轮会挑的文本-实现不一致):openapi served 描述改「字节预算尽力压进 500KB:降质量、必要时降边;病态输入兜底取最小一份」;删除 scaleReviewScreenshot 未使用的 mimeType 参数;16 位权重注释理由改准(缩放输出是 RGBA 4B/px,按两条路径较大者 16 预留,数值不变)。

验证:make test(全仓)与 make lint 在 2672092d 上 exit 0;pnpm api-types --check 无 diff。

第 3 轮意见处置(修复提交 2672092d,全部检查零失败)。 先交代审计结论:连续 3 轮 Act on 已按规则触发方向审计(claude-opus-5-5,只读审整份 diff),定性**有限清单**——方向成立不换路线;共同根因是「契约四处重复叙述导致范围逐轮扩张」与「只测纯函数没测接缝」;修完本轮收口,此后只接受「文本与实现不一致」「接缝缺陷」两类 Act on,其余范围外另开 Issue。 **[P2] head 样本仍是 16 字节、16 位判定恒真**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16754) 属实,已修:`reviewScreenshotHead` 样本 16 → 32 字节(IHDR bit depth 在偏移 24)。并按审计要求把接缝钉死:新增 `TestReviewScreenshotHeadFeedsBytesPerPixel`——真编码 8 位与 16 位 PNG 落盘,经 `reviewScreenshotHead` 取样本算权重断言 8/16。该测试在旧代码上必红(8 位会算出 16),不再依赖任何自报。这处是第 2 轮修复代理谎报完成、我复核也没抓到的常量级缺口,本轮修复由主代理亲自执行并逐常量核验。 **[备注] 提示词缺「取图失败」分支**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16755) 已修:读图预算节新增——某张 `served=scaled` 的 url 取图失败(404)时把该图写进 `unmeasurable`(reason 写明生成失败),不要改读 `original_url` 硬读原图。提示词测试同步锁住该条。 同轮顺手收口(审计预判第 4 轮会挑的文本-实现不一致):openapi `served` 描述改「字节预算尽力压进 500KB:降质量、必要时降边;病态输入兜底取最小一份」;删除 `scaleReviewScreenshot` 未使用的 mimeType 参数;16 位权重注释理由改准(缩放输出是 RGBA 4B/px,按两条路径较大者 16 预留,数值不变)。 验证:`make test`(全仓)与 `make lint` 在 2672092d 上 exit 0;`pnpm api-types --check` 无 diff。 <!-- pr-review-loop:reply -->
Author
Owner

@codex review

@codex review <!-- pr-review-loop:reply -->
- 提示词取图失败分支扩到任一行(404 记 unmeasurable,不重试不改读
  original_url);docs heavy 成因补「文件打不开」
- openapi max 描述补「长边在目标内但文件超 500KB 按原尺寸重编码压预
  算」,与实现及 reviews.md 口径一致
Author
Owner

第 4 轮通过(0×P0-P2),两条 Note 属「文本与实现不一致」——按方向审计的收口规则这正是本阶段接受的两类之一,已随手修掉(6c1b016c):

[Note] 提示词 heavy 行缺「文件不可读」处置、docs 成因漏文件打不开(#167 (comment) 附近)
已修:读图预算的取图失败分支从「仅 served=scaled」扩到任一行——取图 404 记 unmeasurable(reason 写明生成失败或文件不可读),不改读 original_url、不反复重试;docs/internal/reviews.md 两处 heavy 成因补上「文件打不开」。

[Note] openapi max 描述与小而重重编码行为矛盾(#167 (comment) 附近)
已修:max 描述补「长边在目标内但文件超 500KB 的除外——按原尺寸重编码压预算」,skills/fast-ship/references/api.md 已随 make api-docs 再生成,对外契约与实现、reviews.md 口径一致。

验证:受影响测试与 make api-types/make api-docs 重跑通过,全量检查在 6c1b016c 上 exit 0。

第 4 轮通过(0×P0-P2),两条 Note 属「文本与实现不一致」——按方向审计的收口规则这正是本阶段接受的两类之一,已随手修掉(6c1b016c): **[Note] 提示词 heavy 行缺「文件不可读」处置、docs 成因漏文件打不开**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16774 附近) 已修:读图预算的取图失败分支从「仅 served=scaled」扩到**任一行**——取图 404 记 unmeasurable(reason 写明生成失败或文件不可读),不改读 `original_url`、不反复重试;docs/internal/reviews.md 两处 heavy 成因补上「文件打不开」。 **[Note] openapi `max` 描述与小而重重编码行为矛盾**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16774 附近) 已修:`max` 描述补「长边在目标内但文件超 500KB 的除外——按原尺寸重编码压预算」,`skills/fast-ship/references/api.md` 已随 `make api-docs` 再生成,对外契约与实现、reviews.md 口径一致。 验证:受影响测试与 `make api-types`/`make api-docs` 重跑通过,全量检查在 6c1b016c 上 exit 0。 <!-- pr-review-loop:reply -->
fastship-bot left a comment

本轮未发现 P0/P1:缩放、字节预算与清单/取图判定同源的主路径成立;残留一处格式误判会让合法动画 WebP 截图的取图地址直接 404。影响:这类截图在清单标 served=scaled,代理按提示词只能记 unmeasurable,原图明明可读却拿不到。

本轮未发现 P0/P1:缩放、字节预算与清单/取图判定同源的主路径成立;残留一处格式误判会让合法动画 WebP 截图的取图地址直接 404。影响:这类截图在清单标 served=scaled,代理按提示词只能记 unmeasurable,原图明明可读却拿不到。 <!-- fast-ship-review-run:37664579-2be2-423d-87a4-6f4d4b91ffff -->
@ -0,0 +399,4 @@
}
return img, nil
case "image/webp":
img, err := webp.Decode(f)
Collaborator

P2:问题:sniffReviewScaleFormat 把所有 RIFF/WEBP 当成可解码,reviewScreenshotServed 用 webp.DecodeConfig 判定;动画 WebP 的 VP8X 头能过 DecodeConfig(返回画布尺寸),于是清单标 served=scaled,但 x/image/webp 的 Decode 不支持 ANIM/ANMF 首帧,返回 invalid format,scaleReviewScreenshot 转成 errReviewScaleVariantFailed,取图端点回 404(40406)。实测 2400×1400 两帧动画 WebP:DecodeConfig=2400×1400,Decode=webp: invalid format。影响:合法的动画 WebP 截图只要长边 >1536 或文件 >500KB 就被判 scaled,代理只能记 unmeasurable;与“无解码器格式按原图字节透传”的既定口径不一致,且原图仍可流式下发。建议:在嗅探/清单判定阶段读 VP8X flags 的 animation 位(bit1),动画 WebP 归 original_heavy 原图透传;或至少让 webp 像素解码失败按原图透传,并补一条动画 WebP 用例。

P2:问题:sniffReviewScaleFormat 把所有 RIFF/WEBP 当成可解码,reviewScreenshotServed 用 webp.DecodeConfig 判定;动画 WebP 的 VP8X 头能过 DecodeConfig(返回画布尺寸),于是清单标 served=scaled,但 x/image/webp 的 Decode 不支持 ANIM/ANMF 首帧,返回 invalid format,scaleReviewScreenshot 转成 errReviewScaleVariantFailed,取图端点回 404(40406)。实测 2400×1400 两帧动画 WebP:DecodeConfig=2400×1400,Decode=webp: invalid format。影响:合法的动画 WebP 截图只要长边 >1536 或文件 >500KB 就被判 scaled,代理只能记 unmeasurable;与“无解码器格式按原图字节透传”的既定口径不一致,且原图仍可流式下发。建议:在嗅探/清单判定阶段读 VP8X flags 的 animation 位(bit1),动画 WebP 归 original_heavy 原图透传;或至少让 webp 像素解码失败按原图透传,并补一条动画 WebP 用例。
- VP8X 带 ANIM 位的 WebP 在共享判定处直接判 original_heavy(原图透
  传):DecodeConfig 能返回画布尺寸但 Decode 不支持 ANIM/ANMF 首帧,
  标 scaled 会让取图 40406 而原图明明可读
- 测试经 reviewScreenshotHead 真拼头取样本判定(钉死样本长度接缝),
  不再只测纯函数
- openapi/api-docs/reviews.md 的 original_heavy 成因同步补动画 WebP
Author
Owner

第 5 轮意见处置(修复提交 49c401a1,全部检查零失败):

[P2] 动画 WebP 头过 DecodeConfig → 清单标 scaled → 取图 404(#167 (comment))
属实,已修:共享判定 reviewScreenshotServed 里,嗅探为 webp 且 VP8X 特性位带 ANIM(样本偏移 27 bit1,头部样本已读到 32 字节覆盖该位)→ 直接判 original_heavy 透传原图。这类图「头能过但像素不可解」,原图本身可读——清单标 scaled 才是说谎。测试 TestReviewScreenshotServedAnimatedWebp 经 reviewScreenshotHead 真拼头判定(钉死「样本 ≥28 字节才覆盖 ANIM 位」这条接缝,吸取第 3 轮样本长度教训,不再只测纯函数)。

openapi、api-docs(已随 make api-docs 再生成)与 docs/internal/reviews.md 三处 original_heavy 成因同步补上动画 WebP,对外契约与实现口径一致。

验证:make test(全仓)与 make lint 在 49c401a1 上 exit 0;pnpm api-types --check 无 diff。

第 5 轮意见处置(修复提交 49c401a1,全部检查零失败): **[P2] 动画 WebP 头过 DecodeConfig → 清单标 scaled → 取图 404**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16773) 属实,已修:共享判定 `reviewScreenshotServed` 里,嗅探为 webp 且 VP8X 特性位带 ANIM(样本偏移 27 bit1,头部样本已读到 32 字节覆盖该位)→ 直接判 `original_heavy` 透传原图。这类图「头能过但像素不可解」,原图本身可读——清单标 scaled 才是说谎。测试 `TestReviewScreenshotServedAnimatedWebp` 经 `reviewScreenshotHead` 真拼头判定(钉死「样本 ≥28 字节才覆盖 ANIM 位」这条接缝,吸取第 3 轮样本长度教训,不再只测纯函数)。 openapi、api-docs(已随 `make api-docs` 再生成)与 docs/internal/reviews.md 三处 `original_heavy` 成因同步补上动画 WebP,对外契约与实现口径一致。 验证:`make test`(全仓)与 `make lint` 在 49c401a1 上 exit 0;`pnpm api-types --check` 无 diff。 <!-- pr-review-loop:reply -->
fastship-bot left a comment

本轮发现第 5 轮「动画 WebP」修复仍读错 VP8X 特征位偏移(head[27] 是画布高度低字节,不是 flags 的 head[20]):动画 WebP 约一半的概率仍被判 scaled 后取图 404,可正常解码的静态 VP8X WebP 也会被误标 original_heavy。影响个别 WebP 截图的视觉审查:要么整张记为 unmeasurable,要么被提示词劝退而漏审。

本轮发现第 5 轮「动画 WebP」修复仍读错 VP8X 特征位偏移(head[27] 是画布高度低字节,不是 flags 的 head[20]):动画 WebP 约一半的概率仍被判 scaled 后取图 404,可正常解码的静态 VP8X WebP 也会被误标 original_heavy。影响个别 WebP 截图的视觉审查:要么整张记为 unmeasurable,要么被提示词劝退而漏审。 <!-- fast-ship-review-run:b0323b80-de25-458f-86b0-6e413b719107 -->
@ -0,0 +177,4 @@
if len(head) < 28 || string(head[12:16]) != "VP8X" {
return false
}
return head[27]&0x02 != 0
Collaborator

P1:问题:sniffWebpAnimation 判定动画位读的是 head[27]&0x02,但 WebP VP8X 的 flags 字节在偏移 20(RIFF 12 + "VP8X" 4 + chunk size 4),偏移 21-23 是保留字节,24-26 是 canvas width-1,27-29 是 canvas height-1,所以 head[27] 实际是高度低字节;x/image/webp 也按 buf[0] 读 flags(golang.org/x/image@v0.46.0/webp/decode.go:164-176 偏移 20)。同一函数的注释把布局写成「20-23 宽高 + 27 特征位」,与规范相反。用真实文件验证(ffmpeg/ImageMagick 生成,均走真实 reviewScreenshotServed/scaleReviewScreenshot):2400×1401 两帧动画 WebP(ANIM=1,DecodeConfig 成功、Decode 返回 webp: invalid format)→ served=scaled,scaleReviewScreenshot 抛 errReviewScaleVariantFailed,端点回 40406;2400×1400 静态 VP8X WebP(可正常 Decode)→ served=original_heavy,被提示词要求「能不读就不读」。影响:第 5 轮 P2 实际未修复——动画 WebP 是否 404 由画布高度决定(约一半尺寸仍 404,代理记 unmeasurable);静态带 alpha/EXIF 的 VP8X WebP 又有一半尺寸被误标 heavy(本应出缩略图),审查覆盖随机下降。测试 TestReviewScreenshotServedAnimatedWebp 也钉不住这条接缝:它把 flags 写在偏移 30、宽高写在 23-26,函数读 head[27] 得 0,实际是 DecodeConfig 失败才落到 heavy,所以当前测试在错误实现下仍绿。建议:改为 head[20]&0x02(或复用 x/image 的读法),len 下限改成 21;测试用真实字节布局(可像本轮一样用 ffmpeg 生成样本),并同时覆盖「动画→heavy / 静态 VP8X→scaled 或 light」两个方向。

P1:问题:sniffWebpAnimation 判定动画位读的是 head[27]&0x02,但 WebP VP8X 的 flags 字节在偏移 20(RIFF 12 + "VP8X" 4 + chunk size 4),偏移 21-23 是保留字节,24-26 是 canvas width-1,27-29 是 canvas height-1,所以 head[27] 实际是高度低字节;x/image/webp 也按 buf[0] 读 flags(golang.org/x/image@v0.46.0/webp/decode.go:164-176 偏移 20)。同一函数的注释把布局写成「20-23 宽高 + 27 特征位」,与规范相反。用真实文件验证(ffmpeg/ImageMagick 生成,均走真实 reviewScreenshotServed/scaleReviewScreenshot):2400×1401 两帧动画 WebP(ANIM=1,DecodeConfig 成功、Decode 返回 webp: invalid format)→ served=scaled,scaleReviewScreenshot 抛 errReviewScaleVariantFailed,端点回 40406;2400×1400 静态 VP8X WebP(可正常 Decode)→ served=original_heavy,被提示词要求「能不读就不读」。影响:第 5 轮 P2 实际未修复——动画 WebP 是否 404 由画布高度决定(约一半尺寸仍 404,代理记 unmeasurable);静态带 alpha/EXIF 的 VP8X WebP 又有一半尺寸被误标 heavy(本应出缩略图),审查覆盖随机下降。测试 TestReviewScreenshotServedAnimatedWebp 也钉不住这条接缝:它把 flags 写在偏移 30、宽高写在 23-26,函数读 head[27] 得 0,实际是 DecodeConfig 失败才落到 heavy,所以当前测试在错误实现下仍绿。建议:改为 head[20]&0x02(或复用 x/image 的读法),len 下限改成 21;测试用真实字节布局(可像本轮一样用 ffmpeg 生成样本),并同时覆盖「动画→heavy / 静态 VP8X→scaled 或 light」两个方向。
- VP8X 布局:12-15 tag、16-19 size、20 flags、21-23 保留、24-26 宽-1、
  27-29 高-1;ANIM=flags bit1 在偏移 20。上一轮读 head[27] 是高度低
  字节——约 50% 真动画漏判 scaled 后取图 404,静态 VP8X 也会误判
- 测试按精确布局拼 flags 字节(偏移 20),静态 VP8X flags=0 锁不被
  误读——上一版错位下 0x2F&0x02≠0 会暴露
Author
Owner

第 6 轮意见处置(修复提交 6297882c,全部检查零失败):

[P1] VP8X 动画位读错偏移(#167 (comment))
属实,已修:flags 字节在偏移 20(12-15 "VP8X"、16-19 chunk size、20 flags、21-23 保留、24-26 宽-1、27-29 高-1),ANIM=bit1(0x02)。上一轮读 head[27] 是画布高度低字节——静态 VP8X 高度低位带 bit1 会被误判为动画,真动画也会漏判。测试同步按精确布局重拼 flags(偏移 20),并锁静态 VP8X(flags=0)不误伤——旧错位实现下 0x2F&0x02≠0 会红。

这也是我在第 5 轮手算字节布局时犯的常量错误,感谢复核抓到。验证:make test(全仓)与 make lint 在 6297882c 上 exit 0;pnpm api-types --check 无 diff。

第 6 轮意见处置(修复提交 6297882c,全部检查零失败): **[P1] VP8X 动画位读错偏移**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16786) 属实,已修:flags 字节在偏移 **20**(12-15 "VP8X"、16-19 chunk size、20 flags、21-23 保留、24-26 宽-1、27-29 高-1),ANIM=bit1(0x02)。上一轮读 `head[27]` 是画布高度低字节——静态 VP8X 高度低位带 bit1 会被误判为动画,真动画也会漏判。测试同步按精确布局重拼 flags(偏移 20),并锁静态 VP8X(flags=0)不误伤——旧错位实现下 `0x2F&0x02≠0` 会红。 这也是我在第 5 轮手算字节布局时犯的常量错误,感谢复核抓到。验证:`make test`(全仓)与 `make lint` 在 6297882c 上 exit 0;`pnpm api-types --check` 无 diff。 <!-- pr-review-loop:reply -->
fastship-bot left a comment

上一轮 P1(VP8X 动画位读错偏移)已按 head[20]&0x02 修复并用 offset-20 样本锁定,本轮未发现新的功能性缺陷;仅剩与实现矛盾的 WebP 布局注释和测试注释/覆盖的遗留隐患。

上一轮 P1(VP8X 动画位读错偏移)已按 head[20]&0x02 修复并用 offset-20 样本锁定,本轮未发现新的功能性缺陷;仅剩与实现矛盾的 WebP 布局注释和测试注释/覆盖的遗留隐患。 <!-- fast-ship-review-run:76908ac6-6fef-4b95-98a2-16e076c7a35e -->
@ -0,0 +173,4 @@
}
// reviewScreenshotHead 读文件前 32 字节做嗅探与判定样本:PNG 位深(IHDR
// bit depth 字节在偏移 24)、WebP 动画位(VP8X 在偏移 12,payload 第 4 字节
Collaborator

P2:问题:reviewScreenshotHead 的注释仍写旧错位布局——「payload 第 4 字节 bit 6、样本至少 30 字节」,而 sniffWebpAnimation 的注释(本文件 164 行)又把 20-23 写成保留字节;实际 flags 在偏移 20、21-23 才是保留字节,代码已按 head[20]&0x02 修正。影响:上一轮 P1 正是照着这条错误注释手算偏移引入的,后续维护者按注释改回或新增嗅探逻辑时会再次把动画 WebP 判成 scaled(取图 404)或把静态 VP8X 误标 original_heavy,审查覆盖随机下降。建议:两处注释统一为「VP8X flags 在偏移 20,ANIM=bit1(0x02),21-23 保留,样本下限 21 字节」,并同步测试文件里残留的「≥28 字节」说法。

P2:问题:reviewScreenshotHead 的注释仍写旧错位布局——「payload 第 4 字节 bit 6、样本至少 30 字节」,而 sniffWebpAnimation 的注释(本文件 164 行)又把 20-23 写成保留字节;实际 flags 在偏移 20、21-23 才是保留字节,代码已按 head[20]&0x02 修正。影响:上一轮 P1 正是照着这条错误注释手算偏移引入的,后续维护者按注释改回或新增嗅探逻辑时会再次把动画 WebP 判成 scaled(取图 404)或把静态 VP8X 误标 original_heavy,审查覆盖随机下降。建议:两处注释统一为「VP8X flags 在偏移 20,ANIM=bit1(0x02),21-23 保留,样本下限 21 字节」,并同步测试文件里残留的「≥28 字节」说法。
- reviewScreenshotHead 注释写清两处判定各自所需的最小样本(PNG 位深
  ≥25 字节、WebP flags ≥21 字节),去掉旧错位布局描述
- sniffWebpAnimation 注释更正:20 是 flags(不是保留)、21-23 才是保留;
  注明上一轮按错误注释手算偏移的教训,别照注释再算
- 测试补样本 <21 字节保守返回 false
Author
Owner

第 7 轮意见处置(修复提交 9c6ca9ca,全部检查零失败):

[P2] reviewScreenshotHead 注释仍是旧错位布局(#167 (comment))
已修:注释更正为两处判定各自所需的最小样本——PNG 位深 ≥25 字节(IHDR bit depth 在偏移 24)、WebP 动画 flags ≥21 字节(在偏移 20);sniffWebpAnimation 的注释也把「20-23 保留」更正为「20 flags、21-23 保留」,并注明上一轮正是照错误注释手算偏移才引入 P1——别照注释再算一遍。测试补样本 <21 字节保守返回 false(不越界读)。

验证:make test(全仓)与 make lint 在 9c6ca9ca 上 exit 0。

第 7 轮意见处置(修复提交 9c6ca9ca,全部检查零失败): **[P2] `reviewScreenshotHead` 注释仍是旧错位布局**(https://fj.frp.musiver.cn/fjbobo/fast_ship/pulls/167#issuecomment-16798) 已修:注释更正为两处判定各自所需的最小样本——PNG 位深 ≥25 字节(IHDR bit depth 在偏移 24)、WebP 动画 flags ≥21 字节(在偏移 20);`sniffWebpAnimation` 的注释也把「20-23 保留」更正为「20 flags、21-23 保留」,并注明上一轮正是照错误注释手算偏移才引入 P1——别照注释再算一遍。测试补样本 <21 字节保守返回 false(不越界读)。 验证:`make test`(全仓)与 `make lint` 在 9c6ca9ca 上 exit 0。 <!-- pr-review-loop:reply -->
fjbobo merged commit ebebe84706 into main 2026-11-04 10:37:00 +08:00
fjbobo deleted branch int-186/review-screenshot-thumbnail 2026-11-04 10:37:00 +08:00
Sign in to join this conversation.
No description provided.