合并 master 最新生成契约

保留游戏场景生成链路

接入生成幂等与参考图上限

保留权威画布持久化与完美像素能力

解决图片画布生成相关冲突
This commit is contained in:
2026-08-06 11:43:57 +08:00
102 changed files with 18601 additions and 1139 deletions
@@ -138,10 +138,10 @@ Carry the current art spec in `generationInputs.artSpec` and reflect important c
}
```
The top-level `style` field is not the art spec's visual-style prose. It controls deterministic post-processing:
The top-level `style` field is not the art spec's visual-style prose. It appends a short server-side clause to the prompt sent to the provider and enables deterministic post-processing:
- Omitted, `null`, empty string, or `"none"`: disable post-processing without warning.
- `"pixelArt"`: enable pixel-art snapping for ordinary image generation, `kind: "character"`, and icon spritesheet generation.
- Omitted, `null`, empty string, or `"none"`: no clause is appended and no post-processing runs, without warning.
- `"pixelArt"`: append one short pixel-art line to the end of the prompt sent to the provider, and enable pixel-art snapping, for ordinary image generation, `kind: "character"`, and icon spritesheet generation. The line is appended, not substituted — the rest of your prompt is unchanged. For the exact per-kind wording, read the `style` field description in the OpenAPI document; it is the contract, and this guide deliberately does not copy it.
- Unknown strings, or `"pixelArt"` on unsupported kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`: continue without style processing and return `warning.code: "unsupported-image-style"`.
- Non-string JSON values: malformed request, HTTP `400`.
@@ -2991,7 +2991,7 @@
"none",
"pixelArt"
],
"description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 仅支持普通图片(kind 省略)和 character。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
"description": "可选生成风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理,提交给 provider 的提示词与未带该字段时逐字一致pixelArt 仅支持普通图片(kind 省略)和 character,会在提示词末尾追加一行像素风约束(普通图片为「画面为像素风格」,character 为「角色主体为像素风格」)并在回图后执行像素规整。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
},
"size": {
"type": "string",
@@ -3047,7 +3047,7 @@
"type": "array",
"items": {
"type": "string",
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多使用前 5 张,数组上限为 9。"
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 gpt-image-2 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。"
},
"maxItems": 9
},
@@ -3241,7 +3241,7 @@
"type": "array",
"items": {
"type": "string",
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。"
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
},
"maxItems": 8
},
@@ -3372,7 +3372,7 @@
"type": "array",
"items": {
"type": "string",
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 8 张。"
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
},
"maxItems": 8
},
@@ -3405,7 +3405,7 @@
"none",
"pixelArt"
],
"description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理pixelArt 启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
"description": "可选生成风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理,提交给 provider 的提示词与未带该字段时逐字一致;pixelArt 会在提示词末尾追加一行「每个图标素材均为像素风格」并启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。"
},
"model": {
"type": "string",
@@ -3496,7 +3496,7 @@
"type": "array",
"items": {
"type": "string",
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 5 张。"
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。"
},
"maxItems": 5
},
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -14,6 +14,21 @@
- 关联:相关文件、文档、提交或 Issue
```
## `timeout_at` 不能替代显式的预算耗尽预检
- 现象:给完美像素加端点级并发闸后,预算已经耗尽的请求仍然能拿到许可,白占一个名额继续去打几轮全账号 SpacetimeDB 扫描,直到下载那步才失败。
- 原因:`tokio::time::timeout_at` 会先 poll 一次内层 future 再判超时。信号量有空闲许可时 `acquire_owned()` 首次 poll 就绪,于是即使 deadline 早已过去,返回的仍是 `Ok(Ok(permit))` 而不是超时。既有 `acquire_editor_pixel_art_cpu_permit` 里那句 `if Instant::now() >= processing_deadline` 正是为此存在,新写的许可函数漏掉后被单测抓出。
- 处理:所有「先判预算、再等资源」的获取函数都必须在 `timeout_at` 之前显式判一次 `Instant::now() >= deadline` 并直接返回超时错误;这句不是冗余防御。同理,进入排队计数之前也要先做这个预检,避免为注定失败的请求占用队列名额。
- 验证:在有空闲许可时用已过期的 deadline 调用获取函数,只断言返回 `504` 而不是许可;`504` 已足以证明显式预检没有被 `timeout_at` 的首次 poll 绕过。禁止在该用例里读取进程级队列 Atomic 的 before/after;相对断言同样会被并行测试插入。仅靠「信号量占满时超时」的用例发现不了这个问题。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``acquire_editor_pixel_art_snap_permit``acquire_editor_pixel_art_cpu_permit`)。
## 有界等待队列的计数递减必须写在 Drop 里
- 现象:给同步端点加「最多 N 个等待者」的保险丝时,若把计数递减写在正常返回路径上,客户端断连或超时触发会让等待中的 future 被丢弃而跳过递减;计数只增不减,最终队列永久判定为满,接口对所有人返回 `503` 且不会自愈。
- 原因:Rust 的 async future 可以在任意 await 点被取消,取消时只保证 `Drop` 会跑,不保证后续代码会执行。有界队列的入场与离场天然不对称。
- 处理:把递增封进一个 guard 结构体,递减放在它的 `Drop` 实现里;递增本身用 `fetch_update` 的 CAS,不能用「先读后加」——两个线程同时读到 `max - 1` 各自加一就会越界。拿到资源后立即 `drop(guard)` 让出队列名额,不要让它跟着许可一起活到请求结束。
- 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败),并由独立用例覆盖 guard 离开作用域后的计数归还。预算耗尽路径只断言 `504`,不得通过另一个测试也会修改的进程级 static before/after 来推断“未入队”,也不得用串行锁或 `--test-threads=1` 掩盖隔离问题。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``try_enter_bounded_queue``EditorPixelArtSnapQueueGuard`)。
## Linux 生产脚本门禁不能假设本地也是 GNU userland
- 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T``mapfile: command not found``/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders``/private/var/folders` 的系统别名误判为用户符号链接。
@@ -3236,9 +3251,9 @@
- 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。
- 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。该保证只覆盖本地 lease / slot / permit 回收;RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果仍须按 unknown 读取权威事实。
- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib``dropped_lease_releases_slot_and_permit``acquire_times_out_at_pool_acquire_when_pool_is_busy`)。
- 关联:`server-rs/crates/spacetime-client/src/lib.rs``docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`
- 关联:`server-rs/crates/spacetime-client/src/active.rs``docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`
## 后台灰度配置不能从 SpacetimeDB 本地表缓存读取
@@ -4180,3 +4195,10 @@
- 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。
- 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。
- 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。
## 编辑器生成不能把传输重试、参考图截断和客户端 provenance 当成独立小问题(2026-08-05
- 现象:生成 POST 首次已经入队但响应丢失时,客户端自动重试产生第二个任务;第 6 张或更多参考图仍显示在 UI / 元数据里,却没有送给 provider;直接构造请求还能把任意资源 ID 写成最终素材引用。
- 原因:客户端虽在重试中复用 `x-request-id`,队列入口却用随机 job id 生成 dedupe key;前端允许无限追加,api-server 和 provider 用 `.take(...)` 静默截断;`generationInputs.references` 被当成可信持久 provenance。
- 处理:主站生成 POST 禁止自动重试,把显式复用的稳定 request id 接到队列唯一键并校验 replay payload;所有边界显式拒绝超限,前端还要预留主图槽位、统计在途上传,并在上传完成前拒绝模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除和面板切换 / 关闭;reservation 必须绑定原面板上下文,批量部分失败时不能丢弃已经持久化的成功项。入队、完美像素及直接创建资源 / 素材时删除客户端 references,执行时按真实参考源和 owner 资源记录重建权威引用。历史任务比较必须兼容仅差已删除 references 的旧 payload,不能只保留旧 hash 却让 payload 比较误报冲突。
- 验证:覆盖同键同 payload / 不同 payload、普通图片第 6 张、带主图的 GPT-image-2 第 5 张额外引用、provider 6 / 15 张边界、伪造引用删除和 owned 资源 / 素材重建。
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
- 日期:2026-06-11
- 关联故障:release 环境 api-server 周期性全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 超时,`/readyz` 503`reason=spacetime_unhealthy, stage=pool_acquire`),重启后临时恢复。
- 涉及代码:`server-rs/crates/spacetime-client/src/lib.rs`
- 涉及代码:`server-rs/crates/spacetime-client/src/active.rs`
## 故障根因
@@ -28,6 +28,8 @@
3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 `pool_size`,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。
4. `release_connection` 退化为 `drop(lease)`,显式与隐式归还共用同一条兜底路径。
这里的“取消安全”只指本地连接租约、槽位与 permit 可回收。RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果必须按 unknown 读取权威事实对账;`dropped_lease_releases_slot_and_permit` 只覆盖本地 Drop,不构成远端取消测试。
## 验收
- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(44 通过,含上述连接池与缓存连接测试)
File diff suppressed because one or more lines are too long
@@ -123,7 +123,7 @@ POST /api/profile/api-keys
DELETE /api/profile/api-keys/{keyId}
```
前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。外部 OpenAPI 只描述 `/api/external/v1` 下可由 API Key 调用的接口,不混入登录态 API Key 管理接口。
前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。Key 卡片中的创建时间和最近使用时间必须格式化为 `YYYY-MM-DD`,不得直接展示后端时间戳。外部 OpenAPI 只描述 `/api/external/v1` 下可由 API Key 调用的接口,不混入登录态 API Key 管理接口。
## 版本与兼容策略
@@ -15,7 +15,7 @@
允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整画布视图。删除、剪切和隐藏允许撤销,是因为目标只会让内容重新出现。
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。
添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、完美像素新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。
恢复同样执行动态安全检查。移动、层级、分组、锁定、翻转和视图等不会减少内容的操作可以恢复;重新执行删除、剪切、隐藏、删除生成结果或替换当前素材时必须被阻止。
@@ -24,6 +24,7 @@
- 撤销栈和恢复栈均保存操作类型、目标 `CanvasHistorySnapshot` 和创建时间,分别最多保留 60 条。
- 新画布操作把操作前快照写入撤销栈并清空恢复栈;成功撤销把当前快照写入恢复栈,成功恢复把当前快照写回撤销栈。
- 操作类型用于生成用户提示,并对添加、上传、生成和替换等明确会移除当前结果的撤销做保护;其它操作能否应用由当前快照与目标快照的差异检查决定。
- 已有图片完美像素化成功落入画布前记录独立 `perfect-pixel` 历史类型,中文提示使用“完美像素”。该类型与 `generate-image``expand-image``remove-background` 一样属于新增结果保护操作:撤销不得删除派生 PNG,源图继续保留也不改变这一保护语义。像素处理失败、超时或不适用时不写历史。
- 安全检查以稳定的 `layer.id` 判断当前图层是否仍存在;内容身份优先比较对象存储 key、对象标识和媒体地址,序列帧结果比较完整帧列表。`resourceId``sourceResourceId``sourceAssetId` 等内部关联 ID 的延迟回填不视为图片替换。
- 恢复历史快照时,相同 ID 的图层以当前对象为权威,只从目标快照覆盖 `x``y``zIndex``groupId``assetKind``hidden``locked``flipX``flipY`。当前图层的资源关联、内容、媒体、生成元数据、`width` / `height` / `originalWidth` / `originalHeight` 和标题必须保留,不能被异步回填前的旧快照覆盖。
- 当前生成对话框和非活动生成结果按稳定 ID 纳入内容存在性检查,避免恢复操作删除当前生成结果。相同 ID 的生成对话框只从目标快照恢复占位框 `x` / `y` 以及 active / inactive 槽位对应的 `composerOpen`,当前占位框的 `width` / `height` / `originalWidth` / `originalHeight`、当前比例与清晰度等参数、`generating` / `failed` / 完成态、提示词、参考图和任务结果继续以当前状态为准,不能被旧历史快照降级。
@@ -34,6 +35,7 @@
- 修改素材类型的撤销与恢复仍以当前图层内容为权威,但每次成功恢复类型后都要创建与恢复类型一致的正式项目 resource,再回填新 `resourceId`。同一图层的 resource 创建请求使用单调版本号,迟到的旧类型响应不得覆盖更晚的 undo / redo 结果。
- 鼠标拖动在按下时暂存操作前快照;屏幕位移达到点击阈值后才开始改变画布坐标并只提交一条历史,阈值内的指针抖动和单击都不产生位移或历史记录。
- 本地即时结果与后端项目快照结果都必须在生成图层加入画布前写入一条生成历史;生成完成后的自动适合视图不再额外压入视口历史,保证用户第一次撤销就命中生成保护。
- 完美像素的关闭 composer 占位沿用 generation dialog 的内容存在性保护。占位删除已先持久化时,后端 completion 不得用旧 placeholder 复活占位或派生图层;回包时本地占位已删除则前端不应用完成快照或写 `perfect-pixel` 历史,已经持久化的 project resource / 账号素材仍可由资源或素材入口读取。现有布局 CAS 没有 deletion tombstonecompletion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。
- 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。
## 验收重点
@@ -55,3 +57,5 @@
15. 图层移动后从素材库删除关联素材,随后撤销或恢复都不得把已删除图层重新加入画布;普通画布删除未伴随素材库删除时仍可撤销。
16. 打开“修改图片”并输入未提交提示词后,从按钮等非输入控件触发撤销不得关闭弹窗或回退当前草稿。
17. 修改素材类型后立即撤销或快速撤销再恢复,最终只允许最新类型的 resource 响应回填;刷新项目后类型与最后一次成功历史操作一致。
18. 完美像素结果加入画布后只写一条 `perfect-pixel` 历史;第一次撤销命中保护提示,源图和派生 PNG 都不消失。
19. 完美像素处理中删除占位后,本次完成回包不得在本地重新应用结果或写 `perfect-pixel` 历史;删除已先持久化时,后端不得复活占位或结果图层;已成功持久化的资源或账号素材允许保留。
@@ -69,6 +69,21 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存
重复 completion 必须返回同一资源、layer 和 dialog 终态,不得重复插入,也不能因 dialog 暂时缺失而返回 `changed=false` 后仍把任务标记完成。任一步失败时整笔业务写回回滚,任务保留可诊断的失败或可重试状态。
### 3.6 免费同步栅格派生完成
`POST /api/editor/images/pixel-art-snaps` 的完美像素化不是 external job completion:它免费、在当前 HTTP 请求内 inline 执行,不创建任务行,也没有 `job_id / worker_id / lease_token`。前端仍须先创建关闭 composer 的右侧 generation dialog,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前布局,最后把稳定源媒体引用和带非空 `dialogId``canvasCompletion` 一次提交;结构化 / legacy canvas 的完成分流继续由后端决定,前端不能直接写表或本地补造正式 layer。
端点级并发排队、像素读取、静态 PNG / JPEG / WebP 编码门禁、解码、输入限制、legacy 网格步长估算、CPU 并发排队、规整和 PNG 编码全部发生在持久化前。两层排队的位置不同:端点级闸在首次 IO 之前,因此队列满的 `503` 早于任何 SpacetimeDB 读取和 OSS 下载返回;CPU 排队仍在下载之后、规整之前,等待超预算返回 `504`。两者都在持久化前失败,零写入结论不变。strict 与生成风格使用同一 profile、峰值估算、单轴步长补全、walker、采样和编码;仅在横纵两轴都未检测到步长、legacy 即将进入统一网格兜底时拒绝,任一轴已检测到步长时行为和输出完全一致。任一步失败、超时或不适用时不执行最终 OSS PUT,不创建 asset object、`editor_project_resource``editor_asset` 或结果 layer;不得保存原图副本、逻辑低分辨率图、诊断图或前后对比图冒充结果。处理成功时只 PUT 一张最终 PNG,并至多各创建一个 project resource 和一个账号素材;源图已有正式 project resource 时,结果资源的 `source_resource_id` 指向该资源。
该零写入保证只覆盖首个最终 PNG PUT 前的可预判与处理阶段。进入持久化后,PNG / asset object、project resource、账号素材与 canvas completion 仍跨 OSS 和多个 SpacetimeDB procedure,沿用既有非事务顺序;后段失败可以保留此前已经确认的对象或记录,不做自动删除补偿,也不由客户端重放请求。调用方应按 `task_id / object_key / resource_id` 重新读取权威项目和素材快照后显式收口。
同步 completion 写画布前必须读取当前 revision 和 dialog,而不能信任请求中提交时的旧 placeholder
1. dialog 仍存在时,在同一当前布局上完成一个结果 layer,保留源图,并关联结果 resource;
2. dialog 的删除已先持久化时,跳过 layer / dialog 写回,不重建占位、不复活派生图层;已成功持久化的 project resource / 账号素材允许保留;
3. CAS 冲突时不得拿新 revision 原样重放旧整包;按当前项目保存冲突规则重新读取权威快照并显式收口;
4. 客户端回包时若本地 dialog 已删除,不应用完成快照或写历史;现有布局 CAS 没有 deletion tombstonecompletion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`。请求字节可能已发出后的 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 不自动重放,先通过 GET 核对项目 / 素材快照,再由用户显式决定是否再次执行;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。
## 4. 存量迁移
迁移按 canvas 执行 `backfill → hash 核对 → activate`,并保持幂等:
@@ -110,5 +125,6 @@ SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端
- structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用、`asset_kind_override` 和 dialog 状态;标签展示和类型能力判断统一按 `override ?? resource default`。修改当前图层标签与清除覆盖都保持 `resource_id` 和资源行数量不变;复制共享同一资源并复制 override,随后各副本可独立修改 override。两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。
- worker completion 当前以读取时 revision 做 CAS,冲突时拒绝覆盖;V2 保存和保存后快照在同一 procedure 结果内返回,避免“已提交但后续 GET 失败”的不确定结果。lease-fenced 资源 / layer / dialog / job 单事务 completion 仍是后续收口项。
- structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。
- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个最终 PNG、至多一个 project resource 和一个账号素材。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。
- 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。
- 完成 `npm run spacetime:generate`,确认 Rust 表字段、migration、生成 bindings、HTTP DTO 与前端 `assetKindOverride` 形状一致;再运行 `npm run check:spacetime-runtime-access``npm run check:spacetime-schema`、相关 Rust / API / 前端定向测试、`npm run check:encoding``git diff --check`
@@ -46,7 +46,7 @@
- `model`:支持 `gemini-3.1-flash-image-preview`UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`
- `aspectRatio`:按 `x:y` 展示,选项跟随模型。
- `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。
- `style`:可选生成后处理风格;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`
- `style`:可选生成风格,同时影响提交给 provider 的提示词和回图后的像素规整;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`
- `priceMudPoints`:按当前模型和尺寸从编辑器生成计费配置计算;`nanobanana2 1K``12``gpt-image-2 1K``3``gpt-image-2 2K``5`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。
- 模型与尺寸选项:
- `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig``0.5K` 按 VectorEngine 文档传 `"512"`
@@ -65,6 +65,7 @@
- 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。
- `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`
- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。图标链路使用「每个图标素材均为像素风格」,**不得**使用「画面为像素风格」——图集生成后要按纯色抠像,绿幕底必须保持平整,画面级像素化要求会与同一段提示词里的「纯色背景必须平整无纹理、无渐变」互相拆台;一张图内是多个彼此分离的素材,需要逐个点名,避免模型只把其中一部分做成像素块。注入发生在 `build_editor_icon_spritesheet_prompt` 返回之后,该函数签名和输出契约不变。约束句只随工程化提示词写入**原图 spritesheet** 的 `editor_project_resource` prompt 列;透明结果的 prompt 列是 `"去除纯色背景"`,自动拆分的切片是 `"自动拆分图集"`,两者都不含约束句。与普通图片和角色形象不同,本链路**响应体**的 `prompt` 字段返回的也是含约束句的工程化提示词,而不是用户输入——图标请求本身没有 `prompt` 字段(收的是 `iconDescriptions`),因此调用方(含外部 API v1)能直接看到绿幕子句、间距要求和本次新增的像素约束。以上 prompt 列写入与响应字段规则都是既有行为,与 `web/master` 一致,本次只是让被回传的模板多了一行。不新增 OSS PUT、项目资源、图集画布项或切片画布项。尚未约束各素材共用同一像素块大小(`estimate_step_size` 取全图相邻峰间距的第 30 百分位,块大小不一时步长估计会偏),等实测。
- 图标链路以已持久化的带纯色背景 provider 原图实际尺寸为基准;BgFilter 正常成功后,把 Alpha 蒙版回贴到该同尺寸平底原图,再执行像素规整。网格分析源使用平底 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;规整结果不再经过独立的最终尺寸处理,直接上传透明 spritesheet,成功后才进入原有连通域自动拆分。
- 首版固定参数为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;覆盖率 `Σ(A / 255) / N >= 0.375``ΣA > 0` 时输出硬 Alpha `255`,否则输出严格 `[0,0,0,0]`。分析色数不限制最终输出色数。
- 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。
@@ -93,7 +94,7 @@
- 默认提示文本会完整进入 prompt;用户输入不再被解析为素材数量。例如“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”只是一段完整需求,不代表必须生成或拆出 `6` 个素材。
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
- 图标素材生成请求必须带 `model``aspectRatio``imageSize``nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2` 请求必须包含文档映射后的 `size`
- 图标素材面板可选择 `style: "none" | "pixelArt"``none` 完整保持原处理路径,`pixelArt` 在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。
- 图标素材面板可选择 `style: "none" | "pixelArt"``none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
@@ -65,6 +65,7 @@
- 角色面板增加紧凑的 `像素艺术` 勾选项,请求使用可选字符串字段 `style`:未勾选传 `"none"`,勾选传 `"pixelArt"`。该选择可以随现有生成器快照和队列 payload 保存,但不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。
- `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"``kind="character"` 时启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。同一图片生成请求 DTO 被其它 `kind` 复用时,只有普通图片和 `character` 支持 `"pixelArt"`,其它 `kind` 收到该值也按不支持风格降级。
- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。角色链路使用「角色主体为像素风格」,**不得**使用「画面为像素风格」——角色生成后要按纯色抠像,绿幕底必须保持平整,同一段提示词里已写死「纯色背景必须平整无纹理、无渐变」,画面级像素化要求会与之互相拆台;且该提示词已禁止出现角色以外的场景内容,因此只需点名角色本身。注入发生在 `build_editor_character_image_prompt` 返回之后,该函数签名和输出契约不变。约束句**不会**进入角色链路的任何 `editor_project_resource`:原图 resource 的 prompt 列存的是 `role_setting`(用户原文),透明结果的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`;完整提交提示词是否留存取决于 provider:`persist_editor_provider_source_image` 写原图 asset object 元数据时用的是 `actual_prompt.unwrap_or(prompt)`provider 未回 `actualPrompt` 时才存 `submitted_prompt`(含约束句),此时排障可按 `object_key` 查;provider 回了 `actualPrompt` 就存 provider 改写后的文本,该次生成的 `submitted_prompt` 在系统内一处都不落——外部 API 审计的 `request_payload` 只记 `promptChars` 字符数,没有提示词原文。响应体返回的是用户原文,前端显示不变。以上 prompt 列写入与 asset object 元数据规则都是既有行为,与 `web/master` 逐行一致,本次未改动。角色提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,本次未改写,等实测。
- 角色 provider 回图先按统一业务像素矩阵执行交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。归一后的带纯色背景图先持久化并作为 BgFilter 输入;BgFilter 正常成功后,把 Alpha 蒙版回贴到这张同尺寸平底原图,再执行像素规整并上传透明主图。网格分析源使用已收口到实际交付尺寸的平底原图,RGBA 采样源使用 Alpha 已回贴的透明图;软 Alpha 只参与单格覆盖率和 Alpha 加权 RGB 计算,输出 Alpha 硬化为 `0 / 255`
- 首版参数固定为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格覆盖率 `Σ(A / 255) / N >= 0.375``ΣA > 0` 时输出 `A=255`,颜色按 `Σ(A × RGB) / ΣA` 计算;否则输出 `[0,0,0,0]`。分析色数不限制最终输出色数。
- 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级。
@@ -118,7 +119,7 @@
- `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。
- 上传常规参考图后缩略图右下角显示序号。
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model``screenColor``aspectRatio``imageSize`
- 角色面板可选择 `style: "none" | "pixelArt"``none` 的处理路径和产物保持不变,`pixelArt` 在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。
- 角色面板可选择 `style: "none" | "pixelArt"``none` 的处理路径和产物保持不变,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「角色主体为像素风格」并在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。
- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。
- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
+9 -2
View File
@@ -152,8 +152,15 @@ function parseArchiveObjectMembers(artifact) {
longNameTable = artifact.subarray(contentStart, contentEnd);
} else if (/^\/\d+$/u.test(rawName) && longNameTable) {
const nameOffset = Number.parseInt(rawName.slice(1), 10);
const nameEnd = longNameTable.indexOf(0x0a, nameOffset);
const resolvedEnd = nameEnd >= 0 ? nameEnd : longNameTable.length;
// GNU archives use newline-terminated names; MSVC COFF archives use NUL.
const candidateNameEnds = [
longNameTable.indexOf(0x00, nameOffset),
longNameTable.indexOf(0x0a, nameOffset),
].filter((nameEnd) => nameEnd >= 0);
const resolvedEnd =
candidateNameEnds.length > 0
? Math.min(...candidateNameEnds)
: longNameTable.length;
memberName = longNameTable
.subarray(nameOffset, resolvedEnd)
.toString('utf8')
+141
View File
@@ -2102,6 +2102,147 @@ mod tests {
}
}
#[tokio::test]
async fn editor_pixel_art_snap_requires_bearer_auth() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let request_body = serde_json::json!({
"sourceImageSrc": "editor-resource-source",
"projectId": "proj-source",
"canvasCompletion": {
"dialogId": "dialog-pixel-art",
"title": "完美像素",
"placeholder": {
"x": 0,
"y": 0,
"width": 128,
"height": 128,
"originalWidth": 128,
"originalHeight": 128
}
}
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/pixel-art-snaps")
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn editor_pixel_art_snap_rejects_inline_source_before_processing() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_body");
let app = build_router(state);
let request_body = serde_json::json!({
"sourceImageSrc": "data:image/png;base64,AAAA",
"projectId": "proj-source",
"sourceResourceId": "editor-resource-source",
"assetKind": "character",
"generationInputs": { "fields": [], "references": [] },
"canvasCompletion": {
"dialogId": "dialog-pixel-art",
"title": "完美像素",
"placeholder": {
"x": 0,
"y": 0,
"width": 128,
"height": 128,
"originalWidth": 128,
"originalHeight": 128
}
}
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/pixel-art-snaps")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("先上传 OSS"),
"handler should reject inline pixel-art sources: {body_text}"
);
}
#[tokio::test]
async fn editor_pixel_art_snap_requires_dialog_id_before_project_or_media_work() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_dialog");
let app = build_router(state);
let request_body = serde_json::json!({
"sourceImageSrc": "editor-resource-source",
"projectId": "proj-does-not-exist",
"canvasCompletion": {
"title": "完美像素",
"placeholder": {
"x": 0,
"y": 0,
"width": 128,
"height": 128,
"originalWidth": 128,
"originalHeight": 128
}
}
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/pixel-art-snaps")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("canvasCompletion.dialogId"),
"handler should reject missing dialog ids before project lookup: {body_text}"
);
}
#[tokio::test]
async fn editor_generation_json_validation_preserves_unsupported_media_type() {
let state = AppState::new(AppConfig {
@@ -28,6 +28,7 @@ pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas";
const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024;
const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation";
const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -37,7 +38,7 @@ pub(crate) struct EditorGenerationQueuedResponse {
pub(crate) async fn enqueue_editor_generation_job<T>(
state: &AppState,
_request_context: &RequestContext,
request_context: &RequestContext,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
@@ -49,6 +50,12 @@ where
T: Serialize,
{
let job_id = build_prefixed_uuid_id("task-");
let dedupe_key = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
owner_user_id,
job_kind,
request_context.request_id().trim(),
);
enqueue_editor_generation_job_with_identity(
state,
owner_user_id,
@@ -58,11 +65,26 @@ where
price_mud_points,
payload,
job_id.clone(),
format!("editor-canvas:{job_kind}:{job_id}"),
dedupe_key,
)
.await
}
fn build_editor_generation_dedupe_key(
namespace: &str,
owner_user_id: &str,
job_kind: &str,
stable_key: &str,
) -> String {
let mut hasher = Sha256::new();
hasher.update(owner_user_id.trim().as_bytes());
hasher.update(b"\0");
hasher.update(job_kind.trim().as_bytes());
hasher.update(b"\0");
hasher.update(stable_key.as_bytes());
format!("{namespace}:{job_kind}:{:x}", hasher.finalize())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_editor_generation_job_with_identity<T>(
state: &AppState,
@@ -79,18 +101,25 @@ where
T: Serialize,
{
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
enqueue_serialized_editor_generation_job_with_identity(
let job = enqueue_serialized_editor_generation_job_with_identity(
state,
owner_user_id,
job_kind,
source_entity_id,
request_label,
price_mud_points,
request_payload_json,
request_payload_json.clone(),
job_id,
dedupe_key,
)
.await
.await?;
ensure_editor_generation_job_matches_request(
job,
owner_user_id,
job_kind,
request_payload_json.as_str(),
"请求幂等键已用于不同的生成请求,请复用原请求参数或更换请求标识。",
)
}
#[allow(clippy::too_many_arguments)]
@@ -108,15 +137,11 @@ where
T: Serialize,
{
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
let mut hasher = Sha256::new();
hasher.update(owner_user_id.trim().as_bytes());
hasher.update(b"\0");
hasher.update(job_kind.trim().as_bytes());
hasher.update(b"\0");
hasher.update(idempotency_key.as_bytes());
let dedupe_key = format!(
"{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}",
hasher.finalize()
let dedupe_key = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
owner_user_id,
job_kind,
idempotency_key,
);
let requested_job_id = build_prefixed_uuid_id("task-");
let job = enqueue_serialized_editor_generation_job_with_identity(
@@ -132,20 +157,97 @@ where
)
.await?;
ensure_editor_generation_job_matches_request(
job,
owner_user_id,
job_kind,
request_payload_json.as_str(),
"Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
)
}
fn ensure_editor_generation_job_matches_request(
job: ExternalGenerationJobRecord,
owner_user_id: &str,
job_kind: &str,
request_payload_json: &str,
conflict_message: &str,
) -> Result<ExternalGenerationJobRecord, AppError> {
if job.job_kind != job_kind
|| job.owner_user_id != owner_user_id
|| job.request_payload_json != request_payload_json
|| !editor_generation_request_payloads_match(
job_kind,
job.request_payload_json.as_str(),
request_payload_json,
)
{
return Err(
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
"message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
"message": conflict_message,
})),
);
}
Ok(job)
}
fn generation_input_references(value: &Value) -> Option<&Value> {
value
.as_object()
.and_then(|payload| payload.get("generationInputs"))
.and_then(Value::as_object)
.and_then(|generation_inputs| generation_inputs.get("references"))
}
fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -> bool {
let Some(generation_inputs) = value
.as_object_mut()
.and_then(|payload| payload.get_mut("generationInputs"))
.and_then(Value::as_object_mut)
else {
return false;
};
generation_inputs.remove("references").is_some()
}
fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool {
matches!(
job_kind,
EDITOR_IMAGE_GENERATION_JOB_KIND
| EDITOR_IMAGE_EDIT_JOB_KIND
| EDITOR_BACKGROUND_REMOVAL_JOB_KIND
| EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND
| EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND
)
}
fn editor_generation_request_payloads_match(
job_kind: &str,
existing: &str,
requested: &str,
) -> bool {
if existing == requested {
return true;
}
let (Ok(mut existing), Ok(requested)) = (
serde_json::from_str::<Value>(existing),
serde_json::from_str::<Value>(requested),
) else {
return false;
};
// 只兼容部署前旧 payload 有 references、当前 sanitizer 已删除该字段的单向迁移。
// 音频、视频、角色动作等仍会保留 references;如果当前请求也带该字段,就必须完整
// 比较,不能把两个不同请求错误复用成同一任务。
if !job_kind_migrated_away_from_client_generation_references(job_kind)
|| generation_input_references(&existing).is_none()
|| generation_input_references(&requested).is_some()
|| !strip_untrusted_generation_input_references_from_payload(&mut existing)
{
return false;
}
existing == requested
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_editor_generation_job_for_caller<T>(
state: &AppState,
@@ -363,6 +465,190 @@ mod tests {
}
}
#[test]
fn editor_api_request_dedupe_key_is_stable_and_namespaced() {
let first = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
let replay = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
" user-1 ",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
let other_owner = build_editor_generation_dedupe_key(
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
"user-2",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
let external = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
"request-1",
);
assert_eq!(first, replay);
assert_ne!(first, other_owner);
assert_ne!(first, external);
assert!(first.starts_with("editor-api-request-generation:editor_image_generation:"));
}
#[test]
fn external_api_dedupe_key_preserves_legacy_hash_bytes() {
let dedupe_key = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
" user-1 ",
EDITOR_IMAGE_GENERATION_JOB_KIND,
" request-1 ",
);
assert_eq!(
dedupe_key,
"external-api-generation:editor_image_generation:81e38a8eace5f098041b3c240f99053c555ccfa18139295576c9b4ba3d9acff6"
);
}
#[test]
fn replayed_editor_generation_job_must_match_original_request() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
job.request_payload_json = r#"{"prompt":"same"}"#.to_string();
assert!(
ensure_editor_generation_job_matches_request(
job.clone(),
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
r#"{"prompt":"same"}"#,
"幂等冲突",
)
.is_ok()
);
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
r#"{"prompt":"changed"}"#,
"幂等冲突",
)
.expect_err("same request id must reject a different payload");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_legacy_external_job_ignores_only_removed_client_references() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"fields": [{"title": "提示词", "value": "same"}],
"references": [{
"title": "旧客户端引用",
"refType": "asset",
"refId": "asset-forged"
}]
}
}))
.expect("legacy payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"fields": [{"title": "提示词", "value": "same"}]
}
}))
.expect("current payload should serialize");
assert!(
ensure_editor_generation_job_matches_request(
job.clone(),
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.is_ok()
);
let changed = requested.replace("same", "changed");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
changed.as_str(),
"幂等冲突",
)
.expect_err("non-reference payload changes must still conflict");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = "editor_video_generation".to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"references": [{"refType": "asset", "refId": "asset-1"}]
}
}))
.expect("existing video payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"references": [{"refType": "asset", "refId": "asset-2"}]
}
}))
.expect("requested video payload should serialize");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
"editor_video_generation",
requested.as_str(),
"幂等冲突",
)
.expect_err("different retained references must conflict");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_video_job_does_not_use_image_reference_migration_compatibility() {
let mut job = queue_job_fixture("queued", None);
job.owner_user_id = "user-1".to_string();
job.job_kind = EDITOR_VIDEO_GENERATION_JOB_KIND.to_string();
job.request_payload_json = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {
"references": [{"refType": "asset", "refId": "asset-1"}]
}
}))
.expect("existing video payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"generationInputs": {}
}))
.expect("requested video payload should serialize");
let error = ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_VIDEO_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.expect_err("video payloads did not migrate away from references");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn serialize_payload_accepts_persistable_media_references() {
let payload = json!({
File diff suppressed because it is too large Load Diff
@@ -41,7 +41,7 @@ use crate::{
enqueue_editor_image_generation_for_owner,
enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error,
normalize_editor_persisted_media_src, normalize_optional_string,
parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs,
parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
},
external_api_auth::ExternalApiPrincipal,
@@ -991,7 +991,9 @@ fn normalize_project_title(title: Option<String>) -> String {
fn serialize_external_editor_generation_inputs(
generation_inputs: Option<Value>,
) -> Result<Option<String>, AppError> {
serialize_editor_asset_metadata(sanitize_editor_client_generation_inputs(generation_inputs))
serialize_editor_asset_metadata(sanitize_editor_untrusted_generation_inputs(
generation_inputs,
))
}
#[cfg(test)]
@@ -1064,6 +1066,12 @@ mod tests {
"screenColorHex": "#00FF00",
"mattingProvider": "forged-provider",
"mattingModel": "forged-model",
"references": [{
"title": "伪造引用",
"label": "其他用户素材",
"refType": "asset",
"refId": "asset-forged"
}],
"characterAnimation": {"durationSeconds": 4}
})))
.expect("外部编辑器生成输入应可序列化")
@@ -1075,6 +1083,7 @@ mod tests {
assert!(parsed.get("screenColorHex").is_none());
assert!(parsed.get("mattingProvider").is_none());
assert!(parsed.get("mattingModel").is_none());
assert!(parsed.get("references").is_none());
}
#[test]
@@ -1300,6 +1309,14 @@ mod tests {
.get("default")
.is_none()
);
let generation_references = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]
["properties"]["referenceImageSrcs"];
assert_eq!(generation_references["maxItems"], 9);
assert!(
generation_references["items"]["description"]
.as_str()
.is_some_and(|description| description.contains("超限返回 400"))
);
assert_eq!(
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
"array"
@@ -1333,6 +1350,21 @@ mod tests {
.get("targetLayerId")
.is_some()
);
for (schema, max_items) in [
("EditorImageEditRequest", 8),
("EditorIconSpritesheetGenerationRequest", 8),
("EditorUiDesignAssetExtractionRequest", 5),
] {
let references =
&parsed["components"]["schemas"][schema]["properties"]["referenceImageSrcs"];
assert_eq!(references["maxItems"], max_items, "{schema}");
assert!(
references["items"]["description"]
.as_str()
.is_some_and(|description| description.contains("超限返回 400")),
"{schema}"
);
}
assert!(
parsed["paths"]
.get("/api/external/v1/editor/icon-spritesheets/generations")
@@ -68,6 +68,24 @@ impl AppError {
self
}
/// 中文注释:在已有 details 上补一个字段,而不是像 `with_details` 那样整体替换。
/// 用于给沿途传上来的错误追加旁路信息(例如「失败发生在持久化开始之后」),同时保留
/// 下游原本写入的 provider / message 等字段——客户端要靠 message 定位,靠新字段决策。
pub fn with_detail_field(mut self, key: &'static str, value: Value) -> Self {
match self.details.take() {
Some(Value::Object(mut object)) => {
object.insert(key.to_string(), value);
self.details = Some(Value::Object(object));
}
// 中文注释:details 为空或不是对象时按对象重建。本仓库的 details 一律是对象,
// 走到后一个分支说明调用方写法有变,宁可保留标记也不静默丢弃。
_ => {
self.details = Some(serde_json::json!({ key: value }));
}
}
self
}
pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
@@ -22,7 +22,8 @@ use crate::{
generate_editor_scene, get_editor_asset_library, get_editor_generation_pricing,
get_editor_project, list_editor_projects, list_public_editor_project_resources,
load_recent_editor_project, remove_editor_image_background, rename_editor_project,
save_editor_project_layout, split_editor_icon_spritesheet, submit_editor_asset_showcase,
save_editor_project_layout, snap_editor_image_to_pixel_art,
split_editor_icon_spritesheet, submit_editor_asset_showcase,
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
update_editor_project_resource_showcase,
},
@@ -212,6 +213,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/editor/images/pixel-art-snaps",
post(snap_editor_image_to_pixel_art).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/icon-spritesheets/generations",
post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state(
+42
View File
@@ -272,6 +272,7 @@ pub struct AppStateInner {
bgfilter_image_validation_limiter: Arc<Semaphore>,
character_animation_oss_http_client: reqwest::Client,
character_animation_oss_io_limiter: Arc<Semaphore>,
editor_oss_http_client: reqwest::Client,
#[cfg(any())]
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
@@ -530,6 +531,7 @@ impl AppState {
let character_animation_oss_http_client = build_character_animation_oss_http_client()?;
let character_animation_oss_io_limiter =
Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY));
let editor_oss_http_client = build_editor_oss_http_client()?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
@@ -577,6 +579,7 @@ impl AppState {
bgfilter_image_validation_limiter,
character_animation_oss_http_client,
character_animation_oss_io_limiter,
editor_oss_http_client,
#[cfg(any())]
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
#[cfg(any())]
@@ -1317,6 +1320,10 @@ impl AppState {
self.character_animation_oss_io_limiter.clone()
}
pub fn editor_oss_http_client(&self) -> &reqwest::Client {
&self.editor_oss_http_client
}
#[cfg(any())]
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
self.creative_agent_executor.clone()
@@ -2089,6 +2096,31 @@ fn build_character_animation_oss_http_client() -> Result<reqwest::Client, AppSta
})
}
// 中文注释:编辑器图片的 OSS 读写此前每次调用都 `reqwest::Client::new()`,既没有任何
// 超时也没有连接复用——一个半开或黑洞的连接可以无限期挂住,同时占着 HTTP 准入许可和
// 已经读入的最多 32 MiB 图片缓冲。这里收口成进程级共享客户端,给整段请求(含 body
// 流式读写)一个绝对上界,作为所有调用方的兜底。读写共用一个客户端,两个方向都受同一份
// EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES 约束,合用也能让 GET / PUT / HEAD 复用连接池。
//
// connect 取 10s:同区域 OSS 建连是百毫秒级,30s 只会让不可达端点多占 20s 槽位。
// total 取 120s:按较慢的写方向定尺寸——32 MiB 上传在 60s 内要求持续约 4.4 Mbps
// 留一倍余量避免误伤正常流量。读方向不因此变松:完美像素的 GET 另受 30s 处理预算约束,
// 客户端超时只是兜底。
fn build_editor_oss_http_client() -> Result<reqwest::Client, AppStateInitError> {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(120))
.pool_idle_timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(8)
.tcp_keepalive(std::time::Duration::from_secs(60))
.build()
.map_err(|error| {
AppStateInitError::DependencyUnavailable(format!(
"初始化编辑器 OSS HTTP 客户端失败:{error}"
))
})
}
fn build_wechat_client(config: &AppConfig) -> WechatClient {
WechatClient::new(WechatConfig {
app_id: config.wechat_mini_program_app_id.clone(),
@@ -2236,6 +2268,16 @@ mod tests {
);
}
#[test]
fn app_state_reuses_editor_oss_client() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert!(std::ptr::eq(
state.editor_oss_http_client(),
state.editor_oss_http_client(),
));
}
#[test]
fn bgfilter_image_validation_limiter_is_bounded_per_process_role() {
let parent = AppState::new(AppConfig::default()).expect("parent state should build");
+2 -1
View File
@@ -5,7 +5,8 @@ pub mod vector_engine;
pub use pixel_art_snapper::{
PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT,
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art, snap_pixel_art_with_deadline,
PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art_strict_with_deadline,
snap_pixel_art_with_deadline,
};
pub use vector_engine::{
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL,
@@ -48,6 +48,7 @@ const DEADLINE_CHECK_INTERVAL: usize = 4_096;
#[derive(Debug)]
pub enum PixelArtSnapError {
InvalidInput(String),
GridNotDetected,
Decode {
input: &'static str,
message: String,
@@ -63,6 +64,7 @@ impl fmt::Display for PixelArtSnapError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(message) => write!(formatter, "像素规整输入无效:{message}"),
Self::GridNotDetected => write!(formatter, "像素规整未识别到可规整的像素网格"),
Self::Decode { input, message } => {
write!(formatter, "像素规整无法解码 {input}{message}")
}
@@ -154,23 +156,38 @@ impl SnapConfig {
/// image is always a PNG at the original `rgba_source` dimensions. Its alpha
/// channel contains only `0` or `255`, and fully transparent pixels are
/// canonical `[0, 0, 0, 0]`.
pub fn snap_pixel_art(
grid_source: &DownloadedImage,
rgba_source: &DownloadedImage,
) -> Result<DownloadedImage, PixelArtSnapError> {
snap_pixel_art_with_deadline(grid_source, rgba_source, None)
}
/// Deadline-aware variant of [`snap_pixel_art`].
///
/// The deadline is checked before and after non-cooperative codec/resize
/// operations, and periodically inside the K-means, profile, and cell-sampling
/// loops. Exceeding it returns [`PixelArtSnapError::DeadlineExceeded`] without
/// producing a partial image.
/// producing a partial image. Pass `None` to opt out of deadline checks.
pub fn snap_pixel_art_with_deadline(
grid_source: &DownloadedImage,
rgba_source: &DownloadedImage,
deadline: Option<Instant>,
) -> Result<DownloadedImage, PixelArtSnapError> {
snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, false)
}
/// Snap an image unless legacy analysis detects no grid step on either axis.
///
/// Unlike [`snap_pixel_art_with_deadline`], this entry does not synthesize a
/// uniform min-dimension/64 grid when neither axis contains a detectable step.
/// Every other detection, walking, sampling, and encoding behavior — including
/// the deadline semantics — is identical.
pub fn snap_pixel_art_strict_with_deadline(
grid_source: &DownloadedImage,
rgba_source: &DownloadedImage,
deadline: Option<Instant>,
) -> Result<DownloadedImage, PixelArtSnapError> {
snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, true)
}
fn snap_pixel_art_with_grid_policy(
grid_source: &DownloadedImage,
rgba_source: &DownloadedImage,
deadline: Option<Instant>,
reject_uniform_grid_fallback: bool,
) -> Result<DownloadedImage, PixelArtSnapError> {
let deadline = DeadlineGuard::new(deadline);
deadline.check("输入解码")?;
@@ -197,6 +214,9 @@ pub fn snap_pixel_art_with_deadline(
let (profile_x, profile_y) = compute_profiles(&quantized_grid, deadline)?;
let estimated_x = estimate_step_size(&profile_x, config);
let estimated_y = estimate_step_size(&profile_y, config);
if reject_uniform_grid_fallback && estimated_x.is_none() && estimated_y.is_none() {
return Err(PixelArtSnapError::GridNotDetected);
}
let (step_x, step_y) = resolve_step_sizes(
estimated_x,
estimated_y,
@@ -1076,7 +1096,8 @@ mod tests {
let grid = downloaded_png(RgbaImage::from_pixel(8, 8, Rgba([10, 20, 30, 255])));
let rgba = downloaded_png(RgbaImage::from_pixel(8, 9, Rgba([10, 20, 30, 255])));
let error = snap_pixel_art(&grid, &rgba).expect_err("dimensions should mismatch");
let error = snap_pixel_art_with_deadline(&grid, &rgba, None)
.expect_err("dimensions should mismatch");
assert!(
error
.to_string()
@@ -1102,6 +1123,58 @@ mod tests {
));
}
#[test]
fn strict_mode_rejects_only_when_legacy_would_use_uniform_fallback() {
let source = downloaded_png(RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255])));
let legacy = snap_pixel_art_with_deadline(&source, &source, None)
.expect("legacy generation style should retain its uniform fallback");
let strict = snap_pixel_art_strict_with_deadline(&source, &source, None)
.expect_err("explicit strict action should reject an undetected grid");
assert_eq!(decode_output(&legacy).dimensions(), (128, 128));
assert!(matches!(strict, PixelArtSnapError::GridNotDetected));
}
#[test]
fn strict_mode_matches_legacy_when_either_axis_has_a_detected_step() {
for (vertical_lines, horizontal_lines, expected_axes) in [
(true, false, (true, false)),
(false, true, (false, true)),
(true, true, (true, true)),
] {
let mut image = RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255]));
for y in 0..128 {
for x in 0..128 {
if (vertical_lines && x % 8 == 0) || (horizontal_lines && y % 8 == 0) {
image.put_pixel(x, y, Rgba([240, 220, 80, 255]));
}
}
}
let config = SnapConfig::PRODUCTION;
let quantized = quantize_for_analysis(&image, config, DeadlineGuard::new(None))
.expect("test image should quantize");
let (profile_x, profile_y) = compute_profiles(&quantized, DeadlineGuard::new(None))
.expect("test profiles should compute");
assert_eq!(
(
estimate_step_size(&profile_x, config).is_some(),
estimate_step_size(&profile_y, config).is_some(),
),
expected_axes,
);
let source = downloaded_png(image);
let legacy = snap_pixel_art_with_deadline(&source, &source, None)
.expect("legacy processing should succeed with a detected step");
let strict = snap_pixel_art_strict_with_deadline(&source, &source, None)
.expect("strict processing should reuse the detected legacy step");
assert_eq!(strict.bytes, legacy.bytes);
assert_eq!(strict.mime_type, legacy.mime_type);
assert_eq!(strict.extension, legacy.extension);
}
}
#[test]
fn output_keeps_physical_size_and_uses_nearest_blocks() {
let grid_image = RgbaImage::from_pixel(128, 128, Rgba([0, 0, 0, 255]));
@@ -1121,9 +1194,10 @@ mod tests {
}
}
let output = snap_pixel_art(
let output = snap_pixel_art_with_deadline(
&downloaded_png(grid_image),
&downloaded_png(rgba_image.clone()),
None,
)
.expect("pixel snapping should succeed");
let decoded = decode_output(&output);
@@ -1150,8 +1224,10 @@ mod tests {
}
let rgba = downloaded_png(rgba);
let first = snap_pixel_art(&grid, &rgba).expect("first snap should succeed");
let second = snap_pixel_art(&grid, &rgba).expect("second snap should succeed");
let first =
snap_pixel_art_with_deadline(&grid, &rgba, None).expect("first snap should succeed");
let second =
snap_pixel_art_with_deadline(&grid, &rgba, None).expect("second snap should succeed");
assert_eq!(first.bytes, second.bytes);
for pixel in decode_output(&first).pixels() {
@@ -9,7 +9,10 @@ use super::{
effective_request_timeout_ms, request_budget_exhausted_error,
retry_delay_fits_request_deadline,
},
constants::{GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
constants::{
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES,
VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER,
},
curl_transport::{
map_curl_error, send_vector_engine_json_request_with_curl,
send_vector_engine_multipart_edit_request_with_curl,
@@ -320,6 +323,15 @@ pub async fn create_vector_engine_nanobanana_generate_content(
reference_images: &[ReferenceImage],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
if reference_images.len() > VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!(
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。",
reference_images.len()
),
});
}
let model = normalize_vector_engine_image_model(model);
let request_url = vector_engine_nanobanana_generate_content_url(settings, model);
let request_body = build_vector_engine_nanobanana_generate_content_request_body(
@@ -329,12 +341,9 @@ pub async fn create_vector_engine_nanobanana_generate_content(
image_size,
reference_images,
);
let reference_image_count = reference_images.iter().take(14).count();
let reference_image_bytes_total: usize = reference_images
.iter()
.take(14)
.map(|image| image.bytes.len())
.sum();
let reference_image_count = reference_images.len();
let reference_image_bytes_total: usize =
reference_images.iter().map(|image| image.bytes.len()).sum();
let request_params = serde_json::json!({
"model": model,
"promptChars": prompt.trim().chars().count(),
@@ -536,16 +545,22 @@ pub async fn create_vector_engine_image_edit_with_references_and_model(
message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"),
});
}
if reference_images.len() > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES {
return Err(PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!(
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。",
reference_images.len()
),
});
}
let request_url = vector_engine_images_edit_url(settings);
let normalized_size = normalize_image_size_for_model(requested_model, size);
let reference_image_count = reference_images.iter().take(5).count();
let reference_image_bytes_total: usize = reference_images
.iter()
.take(5)
.map(|image| image.bytes.len())
.sum();
let reference_image_count = reference_images.len();
let reference_image_bytes_total: usize =
reference_images.iter().map(|image| image.bytes.len()).sum();
let started_at = std::time::Instant::now();
let mut upstream_model = preferred_vector_engine_upstream_model(requested_model);
let mut recovered_failure_audits = Vec::new();
@@ -1090,6 +1105,63 @@ fn vector_engine_send_retry_jitter_ms() -> u64 {
mod tests {
use super::*;
fn reference_image(index: usize) -> ReferenceImage {
ReferenceImage {
bytes: vec![index as u8],
mime_type: "image/png".to_string(),
file_name: format!("reference-{index}.png"),
}
}
fn test_settings() -> VectorEngineImageSettings {
VectorEngineImageSettings {
base_url: "http://127.0.0.1:9".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
request_deadline: None,
}
}
#[tokio::test]
async fn gpt_image_edit_rejects_six_references_before_network_send() {
let references = (0..6).map(reference_image).collect::<Vec<_>>();
let error = create_vector_engine_image_edit_with_references_and_model(
&reqwest::Client::new(),
&test_settings(),
GPT_IMAGE_2_MODEL,
"测试提示词",
None,
"1024x1024",
1,
references.as_slice(),
"测试图片编辑失败",
)
.await
.expect_err("the provider boundary must reject the sixth reference");
assert!(matches!(error, PlatformImageError::InvalidRequest { .. }));
}
#[tokio::test]
async fn nanobanana_rejects_fifteen_references_before_network_send() {
let references = (0..15).map(reference_image).collect::<Vec<_>>();
let error = create_vector_engine_nanobanana_generate_content(
&reqwest::Client::new(),
&test_settings(),
super::super::constants::NANOBANANA_2_MODEL,
"测试提示词",
None,
"1:1",
"1K",
references.as_slice(),
"测试图片生成失败",
)
.await
.expect_err("the provider boundary must reject the fifteenth reference");
assert!(matches!(error, PlatformImageError::InvalidRequest { .. }));
}
#[tokio::test]
async fn expired_deadline_stops_generation_before_network_send() {
let settings = VectorEngineImageSettings {
@@ -3,3 +3,5 @@ pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c";
pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview";
pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
pub const VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5;
pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14;
@@ -126,7 +126,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
let prompt = prompt.to_string();
let negative_prompt = negative_prompt.map(str::to_string);
let normalized_size = normalized_size.to_string();
let reference_images = reference_images.iter().take(5).cloned().collect::<Vec<_>>();
let reference_images = reference_images.to_vec();
tokio::task::spawn_blocking(move || {
send_multipart_edit_request_with_curl_blocking(
request_url.as_str(),

Some files were not shown because too many files have changed in this diff Show More