完美像素产物按整数倍放大到接近源图

snapper 在采样后、编码前按单一整数 N 做 nearest 放大,保持逻辑图宽高比
生成 pixelArt 与手动完美像素共用该输出,禁止非整数拉回精确源尺寸
手动入口算法指纹升级为 perfect-pixel-v3
同步现役文档与决策记录,并更新被新尺寸带崩的旧断言
This commit is contained in:
2026-08-15 10:01:35 +00:00
parent 1746c77cb1
commit c2cfe4ff03
10 changed files with 188 additions and 34 deletions
@@ -1,5 +1,12 @@
# 决策记录
## 2026-08-15 完美像素编码前按整数倍 nearest 放大到接近源图
- 背景:2026-08-10 起成功产物直接落逻辑网格 PNG,画布按资源实际宽高显示,结果会明显小于源图。用户要求保持逻辑图宽高比,并把产物放大到接近原图;禁止再走非整数 nearest 拉回精确源尺寸(会让逻辑块宽窄不一)。
- 决策:`style="pixelArt"` 与手动 `POST /api/editor/images/pixel-art-snaps` 仍共用 `snap_pixel_art_with_grid_policy`。检测、切线、采样、Alpha、strict 拒兜底不变。`resample` 之后、`encode_png` 之前,用单一整数 N 做 nearest 放大:`N*``(C·W + R·H) / (C² + R²)`,在 `floor` / `ceil`(小于 1 当 1)中取距离平方更小者,并列取较小 N;超单边 `10000` 或总像素 `8294400` 则降 N,最低 `N=1`。只持久化这一张 PNG。手动算法指纹升为 `perfect-pixel-v3`
- 不做:改 walker、透明补边、裁切、横纵不同倍率、Lanczos / bilinear、另存逻辑图、前端框缩放、失败路径、新测试。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md``docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-08-12 Repository checks 采用 CI 与本地共用的单一门禁入口
- 背景:master run 1037 的 Backend/Frontend 已通过,但 `Repository checks` 因 3 个 `simple-import-sort/imports` 错误失败。原 pre-commit 只运行 PrettierPrettier 不处理 ESLint import 排序;推送前又未运行完整仓库 lint,因此本地与 CI 的覆盖范围长期存在漂移。
@@ -4696,8 +4696,8 @@
- 现象:像素规整后的图片虽然保持了源图宽高,放大观察却能看到相邻逻辑块占用的物理列数或行数不同,表现为部分块更宽、部分块更窄;整数倍样例看起来正常,换一张网格数不能整除输入尺寸的图才复现。
- 原因:逻辑图宽高为检测后的列数、行数。把 `C × R` 的逻辑图用 nearest 恢复到 `W × H` 时,只要 `W % C != 0``H % R != 0`,目标栅格就只能在不同逻辑像素间分配 `floor / ceil` 数量的列或行;nearest 能避免混色,却不能让非整数缩放后的块严格等大。只用 `128 × 128 → 64 × 64 → 128 × 128` 这类整数倍测试会掩盖问题。
- 处理:最终资产直接编码一格一像素的逻辑分辨率 PNG,不再执行输入 / 交付尺寸 nearest 恢复,也不要求成功输出与源图或占位尺寸相等。普通图片和角色的前置 Lanczos 交付尺寸归一仍用于确定检测输入;平底网格源与透明 RGBA 源仍必须同尺寸,不能把“取消输出同尺寸”误解为放开两个内部采样坐标系
- 验证:使用至少一组逻辑列数或行数不能整除输入尺寸的图片,断言输出宽高等于切线数减一而不是输入宽高;同时核对响应、project resource、账号素材和结果 layer 都记录最终 PNG 实际尺寸,且只持久化一个最终 PNG,没有输入尺寸恢复版、诊断图或额外资源。失败降级用例继续验证 Alpha / 交付尺寸守卫,不应因成功输出改为逻辑分辨率而删除
- 处理:采样仍是一格一像素;编码前只允许横纵同一整数 N 的 nearest 放大,N 取最接近规整输入尺寸的正整数,禁止非整数拉回精确 `W × H`。成品宽高比等于逻辑图,尺寸接近但不保证等于源图或占位。普通图片和角色的前置 Lanczos 交付尺寸归一仍用于确定检测输入;平底网格源与透明 RGBA 源仍必须同尺寸。
- 验证:整数倍样例与非整除样例都不得出现宽窄不一的逻辑块;响应、project resource、账号素材和结果 layer 都记录最终 PNG 实际尺寸,且只持久化一个最终 PNG,没有未放大逻辑图、输入尺寸恢复版、诊断图或额外资源。失败降级用例继续验证 Alpha / 交付尺寸守卫。
- 关联:`server-rs/crates/platform-image/src/pixel_art_snapper.rs``server-rs/crates/api-server/src/editor_project.rs``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## Codex CLI 节点不能把进程终态当成 Runtime 提交证据(2026-08-10
@@ -54,7 +54,7 @@
- 普通图片与角色在 provider 回图后先按统一业务像素矩阵尝试交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。普通图片随后以这张实际交付尺寸图同时作为网格分析源和 RGBA 采样源;角色先持久化同尺寸平底原图并交给 BgFilter,正常成功后把 Alpha 蒙版回贴到该平底原图,再以平底原图分析网格、以透明 RGBA 图采样。图标仍以已持久化的平底 provider 图尺寸为基准,BgFilter 成功并回贴 Alpha 后执行同样的双输入规整。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、相邻边缘峰间距使用线性插值 `P30` 估算步长、固定色板关闭、K-means 最大采样 `262144`
- 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均按像素后处理失败的 best-effort 规则保留进入该步骤前的图片。
- 单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;单格覆盖率按 `Σ(A / 255) / N` 计算。覆盖率大于等于 `0.375``ΣA > 0` 时输出硬 Alpha `255`,否则输出严格的 `[0,0,0,0]`;最终 Alpha 只允许 `0 / 255`。分析用 16 色只负责网格识别,不限制最终输出色数。
- 逻辑低分辨率图只存在于内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,并直接替换原本即将持久化的最终图片字节。普通图片和角色的输入已经过前置 Lanczos 交付尺寸归一,或在无法安全归一时保留 provider 实际尺寸;图标输入以已持久化平底原图的实际尺寸为准。nearest 不替代前置尺寸归一,规整完成后不再执行第二次 Lanczos 或其它尺寸恢复。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。
- 逻辑网格图只存在于 snapper 内存;采样后按单一整数倍 nearest 放大到最接近规整输入尺寸,再编码为唯一最终 PNG。横纵共用同一 N,保持逻辑图宽高比,禁止非整数拉回精确源尺寸、补边、裁切或 Lanczos / bilinear。普通图片和角色的规整输入已经过前置 Lanczos 交付尺寸归一,或在无法安全归一时保留 provider 实际尺寸;图标输入以已持久化平底原图的实际尺寸为准。整数倍放大不替代前置尺寸归一,规整完成后不再执行第二次尺寸插值。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。
- 像素模式的持久化增量必须为零:普通图片仍只上传原有一张最终主图;角色仍只保留原有 provider 原图与透明主图;图标仍只保留原有 provider 原图、透明图集和实际成功的切片。禁止保存逻辑低分辨率图、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。
- 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。
- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。
@@ -67,9 +67,9 @@
- B 层的边界是可验证的、且已确认只服务完美像素:`requiresLiveSession: true` 全仓仅有一处置位(完美像素提交路径),`claimActiveInlineGenerationDialog` / `releaseActiveInlineGenerationDialog` / `hasActiveInlineGenerationDialog` 的全部五个调用点也都在完美像素的提交、重试与恢复上。直接体量:`perfectPixelOperationStore.ts` 203 行(测试 228 行)、`useInlineGenerationPlaceholderExpiry.ts` 140 行(测试 401 行)、`hydratePerfectPixelOperation` 128 行,加上工作流里的恢复 effect 与窗口锚定,生产代码约 1100 行、测试约 2500 行(后两个数字是估算,前面几个是实测)。同为免费、同步、无 durable job 的手动图集拆分只用约 85 行客户端代码(失败即报错,`taskId` 用随机 UUID,无幂等、无对账),是本仓库对同类问题的既有廉价答案;完美像素额外的 B 层是**特例而非范式**,不得据它给其它链路加同样的机制。
- 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入**本机账本**(占位本身只带 `perfectPixelOperationId` 标记)并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。
- 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功后形成稳定 `perfectPixelOperation` 并**同步写入本机账本**`perfectPixelOperationStore`owner + project 双键的 localStorage),布局里只留 `perfectPixelOperationId` 标记。原先的 strict layout save 通道(60 秒绝对预算、revision ACK 前 POST 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯。被解除的是**客户端侧**「拿不到 revision ack 就拒发」这一层阻断;端到端依赖仍在——布局 PATCH 被校验拒绝、占位因此从未落库时,POST 仍会被服务端以 409 拒收。POST 前仍然 `await` 一次 best-effort 布局保存——服务端要求占位**此前已经持久化**,否则 `validate_editor_pixel_art_snap_placeholder_exists` 直接 409;但 best-effort 不再提供成功 ACK,因此客户端**无法证明**该前置已满足,只能提高满足它的概率(占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失)。该 flush 没有整体上限,所以 75 秒对账窗口必须在 flush 返回、authority 复核通过之后才锚定,且首次提交与人工重试同此口径;锚定只覆盖 `submittedAt / reconcileUntil`,按同一 `operationId` 覆盖账本,request 与 dialog / operation / task identity 逐字节不变。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`;重试请求必须与账本中的 POST JSON byte-for-byte 一致且不得重新上传。**明确接受的行为,不是缺口**:占位恢复可删除之后,用户删掉未收口占位再从源图发起会得到第二个 identity,旧的服务端操作若迟到落库就会多出一份素材,两个 `taskId` 无法幂等合并。按上文的优先级判据,这属于「已生成资源丢失关联」而非主链路故障,代价是用户自行删掉多余素材,**不得**通过让本机账本参与防重来「闭合」——那是被明令禁止的「禁止一张图处理两遍」。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。
- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebPGIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对唯一的逻辑分辨率 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;resource、asset、响应与图层使用该 PNG 的实际宽高,不要求与源图或占位尺寸相等,也不得另存输入尺寸恢复版、诊断图或前后对比图。
- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebPGIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对唯一最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;该 PNG 是逻辑网格经整数倍 nearest 放大后的结果,宽高比等于逻辑图,尺寸接近但不保证等于源图或占位;resource、asset、响应与图层使用该 PNG 的实际宽高,也不得另存未放大的逻辑图、输入尺寸恢复版、诊断图或前后对比图。
- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。后端先纯计算精确 object key 和候选 project resource,再调用只读 SpacetimeDB preflight 校验自定义素材目录归属、复用权威 completion planner,并执行 legacy / structured 的 2 MiB 总量与 512 KiB 单项门禁;默认目录尚未创建时允许通过,preflight 不写库。preflight 与 PUT / HEAD / 原子 persist 共用 60 秒绝对 deadlinepreflight 失败或超时不得 PUT,也不得带 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交,并重新校验目录、布局、幂等身份与 revision。preflight 不加锁或 reservation,所以通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下 OSS 孤儿对象;这是本次最小修复明确保留的 TOCTOU 边界。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;一旦 owner-scoped 项目快照已发现同 operation 的稳定 resource,本次 POST 不再执行 candidate-key exact replay,而是直接返回 `operationResultAlreadyExists=true` 并交由 GET 对账;输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。
- 手动入口的算法指纹随逻辑分辨率输出升级为 `perfect-pixel-v2`。在完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,后端必须在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`,并携带已鉴权的稳定 `resultResourceId`;不再按 candidate object key 继续 exact replay。前端 initial 与 retry 两条 catch 都按稳定资源与 task GET 项目对账,由权威快照明确 `applied``dialog-missing``conflict`;即使稳定记录的 `taskId` 损坏,也必须据 `resultResourceId` 找到该记录并失败关闭为冲突,不得持续等待。该标记表示旧权威结果已存在,不得与“本次 PUT 已开始”的 `resultPersistenceStarted` 混用;发布时仍须排空旧算法实例以规避独立新操作在 preflight 到提交之间的跨版本 TOCTOU。
- 手动入口的算法指纹随整数倍接近原图放大升级为 `perfect-pixel-v3`。在完成请求基础校验、owner-scoped 项目读取与占位验证后,只要同一稳定 `resourceId` 已存在,后端必须在来源解析、OSS GET、像素规整、candidate object key、preflight 与 OSS PUT 前返回 `409 + operationResultAlreadyExists=true`,并携带已鉴权的稳定 `resultResourceId`;不再按 candidate object key 继续 exact replay。前端 initial 与 retry 两条 catch 都按稳定资源与 task GET 项目对账,由权威快照明确 `applied``dialog-missing``conflict`;即使稳定记录的 `taskId` 损坏,也必须据 `resultResourceId` 找到该记录并失败关闭为冲突,不得持续等待。该标记表示旧权威结果已存在,不得与“本次 PUT 已开始”的 `resultPersistenceStarted` 混用;发布时仍须排空旧算法实例以规避独立新操作在 preflight 到提交之间的跨版本 TOCTOU。
- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 在 pre-POST flush 返回、authority 复核通过之后、POST 发出之前建立统一 75 秒绝对窗口(该 flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光),POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。
- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`(账本在本机,布局只留 `perfectPixelOperationId`)。**用户可以随时删除该占位**,任何状态都不例外、也不弹确认:删除不撤销任何在途请求,结果照常落库并进素材库,服务端发现 dialog 已不在会返回 `DialogMissing`;封锁用户删除自己画布上的元素不是可接受的代价。删除后**结果不再自动回填画布**(服务端发现 dialog 已不在会返回 `DialogMissing`),这是用户主动放弃的结果,不得判定为缺陷;但对账本身不会因此停止——当前标签页已经在飞的 Promise 会继续读到终态,本机账本也会以孤儿身份在下次加载被读一次,结果确已落库时仍会提示用户去素材库取。未删除时用户可继续 GET 对账或显式按原 identity 重放。人工重试在 pre-POST flush **之后**才刷新观察窗口(同上一节的锚定口径),POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstonecompletion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。
- 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 与其它占位同样可被立即删除,写正常的 `delete-generation-result` 历史并清理 identity;删除确认只对**计费**生成成立(现成弹窗讲的是「已消耗的泥点不会返还」,而完美像素 `generation_cost_mud_points = 0`),判据收敛为具名的 `requiresGenerationDeleteConfirmation`。低层 `removeCanvasGenerationDialogById` 必须无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因。
@@ -182,7 +182,7 @@
- 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
- 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图进入改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。
- 普通图片、角色和图标面板显示 `像素艺术` 勾选项并正确提交 / 恢复 `style: "none" | "pixelArt"`;其它生成或编辑面板不显示该选项。旧 payload、未知字符串、不支持 `kind` 和非字符串输入分别按本方案约定的兼容或错误语义处理。
- `pixelArt` 输出 Alpha 只包含 `0 / 255`;普通图片和角色先完成 Lanczos 交付尺寸归一,再由 snapper 使用 nearest 把逻辑网格恢复到同一输入尺寸,规整后不得再次执行尺寸插值。成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item,逻辑低分辨率图不得出现在 OSS 或响应资源快照中。
- `pixelArt` 输出 Alpha 只包含 `0 / 255`;普通图片和角色先完成 Lanczos 交付尺寸归一,再由 snapper 按整数倍 nearest 放大到最接近该输入尺寸,规整后不得再次执行尺寸插值。成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item,未放大的逻辑网格图不得出现在 OSS 或响应资源快照中。
- 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。
- 画布常用快捷键必须与右上角快捷键弹窗一致;新增快捷键时应同步更新 `ImageCanvasShortcutModel`、快捷键 hook 单测和本方案。输入框、文本域和 contenteditable 聚焦时不得触发画布编辑快捷键。
- 撤销或恢复画布布局时不得覆盖同 ID 生成对象当前的任务生命周期、提示词、参考图和结果;上传持久化延迟回填内部资源 ID 不得把安全移动误判为素材替换。生成结果必须在加入画布前写入生成历史,自动适合视图不得覆盖这条栈顶记录。
File diff suppressed because one or more lines are too long
@@ -52,7 +52,7 @@ v1 只开放以下能力:
图片生成、图标 spritesheet 和 UI 素材提取的 completed compact `result` 可携带可选结构化 `warning { code, reason }`;任务查询顶层 `warning` 是可直接展示的有界摘要。外部 OpenAPI 当前公开四个稳定 `code`
- `postprocess-failed-source-preserved`:生成成功,但透明处理、像素规整等后处理未完成,接口保留仍可使用的原图或进入该步骤前的结果。
- `dimension-restore-fallback`:该告警只描述进入可选 `pixelArt` 处理前的交付尺寸归一结果;无法安全归一时,在该处理边界保留 provider 回图尺寸。它不描述或约束 `pixelArt` 成功后的最终尺寸;若随后像素规整成功,最终产物仍是逻辑分辨率 PNG,不能据此推断最终宽高等于 provider 回图。
- `dimension-restore-fallback`:该告警只描述进入可选 `pixelArt` 处理前的交付尺寸归一结果;无法安全归一时,在该处理边界保留 provider 回图尺寸。它不描述或约束 `pixelArt` 成功后的最终尺寸;若随后像素规整成功,最终产物是整数倍放大后的 PNG,不能据此推断最终宽高等于 provider 回图。
- `unsupported-image-style`:请求的图片后处理风格未知或不适用于当前生成类型,接口按无风格继续生成。
- `multiple-generation-warnings`:同一成功响应合并了不同 `code` 的多条非阻断告警,具体原因按顺序拼接在 `reason`
@@ -78,7 +78,7 @@ worker 完成生成任务时,`api-server` 先把 Provider / OSS 结果准备
`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;不得保存原图副本、诊断图或前后对比图冒充结果。处理成功时,snapper 直接编码并 PUT 唯一一张 `(columns.len() - 1) × (rows.len() - 1)` 逻辑分辨率 PNG,不再 nearest 恢复到源图或交付尺寸,并至多各创建一个 project resource 和一个账号素材;源图已有正式 project resource 时,结果资源的 `source_resource_id` 指向该资源。结果 resource、asset、响应与 layer 使用最终 PNG 的实际宽高,不要求与源图或 generation dialog 占位尺寸相等,也不得另存输入尺寸恢复版。
端点级并发排队、像素读取、静态 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;不得保存原图副本、诊断图或前后对比图冒充结果。处理成功时,snapper 将逻辑网格按单一整数倍 nearest 放大到最接近规整输入的尺寸后编码并 PUT 唯一一张最终 PNG,禁止非整数拉回源图或交付尺寸,并至多各创建一个 project resource 和一个账号素材;源图已有正式 project resource 时,结果资源的 `source_resource_id` 指向该资源。结果 resource、asset、响应与 layer 使用最终 PNG 的实际宽高,宽高比等于逻辑图,尺寸接近但不保证等于源图或 generation dialog 占位,也不得另存未放大逻辑图或输入尺寸恢复版。
该零写入保证只覆盖首个最终 PNG PUT 前的可预判与处理阶段。进入持久化后,PNG / asset object、project resource、账号素材与 canvas completion 仍跨 OSS 和多个 SpacetimeDB procedure,沿用既有非事务顺序;后段失败可以保留此前已经确认的对象或记录,不做自动删除补偿,也不由客户端重放请求。调用方应按 `task_id / object_key / resource_id` 重新读取权威项目和素材快照后显式收口。
@@ -89,7 +89,7 @@ worker 完成生成任务时,`api-server` 先把 Provider / OSS 结果准备
3. CAS 冲突时不得拿新 revision 原样重放旧整包;按当前项目保存冲突规则重新读取权威快照并显式收口;
4. 客户端回包时若本地 dialog 已删除,不应用完成快照或写历史;现有布局 CAS 没有 deletion tombstonecompletion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`。请求字节可能已发出后的 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 不自动重放,先通过 GET 核对项目 / 素材快照,再由用户显式决定是否再次执行;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。
generation dialog 的 placeholder 宽高只参与占位与完成落点,不是结果媒体尺寸约束;completion 必须使用最终 PNG 资源的实际宽高构造结果 layer,不能为了匹配 placeholder 再缩放或拒绝逻辑分辨率结果。
generation dialog 的 placeholder 宽高只参与占位与完成落点,不是结果媒体尺寸约束;completion 必须使用最终 PNG 资源的实际宽高构造结果 layer,不能为了匹配 placeholder 再缩放或拒绝整数倍放大结果。
## 4. 存量迁移
@@ -132,6 +132,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 已使用 durable receipt 与统一原子提交;V2 布局 CAS、object/resource/asset/binding、job 终态和 receipt 在同一 procedure 结果内返回。故障注入必须证明资产校验失败与 canvas revision 冲突均为零部分写入,成功后重放不新增记录、事件或 revision。
- structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。
- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个逻辑分辨率最终 PNG、至多一个 project resource 和一个账号素材,所有宽高均取该 PNG 实际值且允许与源图 / 占位不同。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。
- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个整数倍放大后的最终 PNG、至多一个 project resource 和一个账号素材,所有宽高均取该 PNG 实际值且允许与源图 / 占位不同。处理中占位删除已先持久化时,完成请求不复活 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`
@@ -75,11 +75,11 @@
- 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 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,不再经过独立的最终尺寸处理,成功后才进入原有连通域自动拆分。
- 图标链路以已持久化的带纯色背景 provider 原图实际尺寸为基准;BgFilter 正常成功后,把 Alpha 蒙版回贴到该同尺寸平底原图,再执行像素规整。网格分析源使用平底 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;这两份内部输入仍必须同尺寸。规整结果按一格一输出像素采样后,再按单一整数倍 nearest 放大到最接近规整输入的尺寸,编码为透明 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 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。
- snapper 直接把逻辑分辨率图编码为最终透明 spritesheet不得再用 nearest 或其它插值恢复到前述平底 provider 原图尺寸。图标链路继续不执行角色链路的前置 Lanczos 交付尺寸归一,最终透明图集宽高仍可以与规整输入不同。实现应复用 Alpha 回贴阶段读取的平底 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。
- 开启或关闭像素风格都保持现有 provider 原图、透明图集和实际成功切片的持久化与画布数量不变。开启时透明图集本身就是唯一的逻辑分辨率 PNG;禁止另存输入尺寸恢复版、像素化前后双份图集、预览或诊断图,也不新增 asset kind、项目资源、画布 item、任务类型或数据库字段。资源、响应和画布结果使用最终 PNG 的实际宽高,不要求与 provider 原图或生成占位尺寸相等。
- snapper 把整数倍放大后的图编码为最终透明 spritesheet禁止非整数拉回平底 provider 原图尺寸。图标链路继续不执行角色链路的前置 Lanczos 交付尺寸归一,最终透明图集宽高比等于逻辑图,尺寸接近但不保证等于规整输入。实现应复用 Alpha 回贴阶段读取的平底 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。
- 开启或关闭像素风格都保持现有 provider 原图、透明图集和实际成功切片的持久化与画布数量不变。开启时透明图集本身就是唯一的整数倍放大 PNG;禁止另存未放大逻辑图、输入尺寸恢复版、像素化前后双份图集、预览或诊断图,也不新增 asset kind、项目资源、画布 item、任务类型或数据库字段。资源、响应和画布结果使用最终 PNG 的实际宽高,不要求与 provider 原图或生成占位尺寸相等。
- 图标图集的 BgFilter `flat` 调用固定使用 `cross_check=on`。BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,都统一只保留 provider 原图且不拆分,像素规整不运行;像素规整自身失败但透明图仍通过完整解码和尺寸守卫时,才保留该透明图并继续上传和拆分,通过通用 `warning` 非致命提示,不退款。`sliceWarning` 继续只表达可信透明图成功后的自动拆分失败。
## 去背与保存
@@ -103,7 +103,7 @@
- 默认提示文本会完整进入 prompt;用户输入不再被解析为素材数量。例如“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”只是一段完整需求,不代表必须生成或拆出 `6` 个素材。
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
- 图标素材生成请求必须带 `model``aspectRatio``imageSize``nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2` 请求必须包含文档映射后的 `size`
- 图标素材面板可选择 `style: "none" | "pixelArt"``none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,直接持久化逻辑分辨率透明图集;该图集可以与规整输入尺寸不同,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。
- 图标素材面板可选择 `style: "none" | "pixelArt"``none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,直接持久化整数倍放大后的透明图集;该图集宽高比等于逻辑图、尺寸接近规整输入,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。
- 图标素材生成可以上传普通附加参考图;提交时图标主规范图走 `referenceId`,且只提交当前 owner 的项目资源 ID 或素材 ID。普通附加参考图单独走 `referenceImageSrcs`,继续允许稳定 `objectKey` / 项目资源 ID / 素材 ID,但禁止 Data URL / Blob URL。两类引用都写入 `generationInputs.references`,不得用普通附加参考图协议放宽主规范边界。
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
@@ -69,8 +69,8 @@
- 角色 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 已回贴的透明图并走非致命降级。
- snapper 把每对相邻横纵切线围成的采样单元各压成一个输出像素,并把这张逻辑分辨率图直接编码为最终透明 PNG;不得再用 nearest 或其它插值恢复到当前 RGBA 输入尺寸。前述 Lanczos 交付尺寸归一继续保留,它只定义网格分析源与 RGBA 采样源的共同坐标系;两份内部输入仍必须同尺寸,最终 PNG 宽高可以与该输入不同。实现应复用 Alpha 回贴阶段读取的已持久化平底原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。
- 开启或关闭像素风格都保持现有 provider 原图与透明主图两份产物、项目资源和画布图层数量不变。开启时透明主图本身就是唯一的逻辑分辨率 PNG;禁止另存输入尺寸恢复版、像素化前后双份主图、预览或诊断图,也不新增 asset kind、画布 item 或任务类型。资源、响应和画布结果使用最终 PNG 的实际宽高,不要求与生成交付尺寸或占位尺寸相等。
- snapper 把每对相邻横纵切线围成的采样单元各压成一个输出像素,再按单一整数倍 nearest 放大到最接近规整输入的尺寸后编码为最终透明 PNG;禁止非整数拉回精确输入尺寸。前述 Lanczos 交付尺寸归一继续保留,它只定义网格分析源与 RGBA 采样源的共同坐标系;两份内部输入仍必须同尺寸,最终 PNG 宽高比等于逻辑图,尺寸接近但不保证等于该输入。实现应复用 Alpha 回贴阶段读取的已持久化平底原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。
- 开启或关闭像素风格都保持现有 provider 原图与透明主图两份产物、项目资源和画布图层数量不变。开启时透明主图本身就是唯一的整数倍放大 PNG;禁止另存未放大逻辑图、输入尺寸恢复版、像素化前后双份主图、预览或诊断图,也不新增 asset kind、画布 item 或任务类型。资源、响应和画布结果使用最终 PNG 的实际宽高,不要求与生成交付尺寸或占位尺寸相等。
- 本功能不修改 BgFilter `flat` 调用、`cross_check=on`、fallback、Alpha 回贴或默认关闭 despill 的现状。BgFilter 最终失败时沿用只保留 provider 原图的既有收口且不运行像素规整;像素规整自身失败时保留已成功的透明图并继续原有持久化,通过通用 `warning` 非致命提示,不退款。
- `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架:
@@ -119,7 +119,7 @@
- `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。
- 上传常规参考图后缩略图右下角显示序号。
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model``screenColor``aspectRatio``imageSize`
- 角色面板可选择 `style: "none" | "pixelArt"``none` 的处理路径和产物保持不变,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「角色主体为像素风格」并在 Alpha 回贴后执行内存像素规整,直接持久化逻辑分辨率透明 PNG;该 PNG 可以与规整输入尺寸不同,最终 OSS PUT、项目资源和画布图层数量不得增加。
- 角色面板可选择 `style: "none" | "pixelArt"``none` 的处理路径和产物保持不变,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「角色主体为像素风格」并在 Alpha 回贴后执行内存像素规整,直接持久化整数倍放大后的透明 PNG;该 PNG 宽高比等于逻辑图、尺寸接近规整输入,最终 OSS PUT、项目资源和画布图层数量不得增加。
- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。
- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
@@ -208,7 +208,7 @@ const EDITOR_OPERATION_RESULT_RESOURCE_ID_DETAIL: &str = "resultResourceId";
const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap";
const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel";
const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative";
const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v2";
const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v3";
static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock<Arc<tokio::sync::Semaphore>> = LazyLock::new(|| {
Arc::new(tokio::sync::Semaphore::new(
EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY,
@@ -3464,8 +3464,8 @@ where
None
};
// 中文注释:普通图片的逻辑像素规整在交付尺寸归一之后进行snapper 会把结果
// 还原回输入尺寸,因此这里不再需要二次恢复交付尺寸。
// 中文注释:普通图片的逻辑像素规整在交付尺寸归一之后进行snapper 在编码前
// 按接近输入尺寸的整数倍 nearest 放大,父流程不再二次恢复交付尺寸。
if !is_character_generation && image_style == EditorImageGenerationStyle::PixelArt {
let (pixel_art_image, pixel_art_error) =
snap_editor_pixel_art_or_original(image, request_context.external_call_deadline())
@@ -15470,7 +15470,7 @@ mod tests {
assert!(legacy_error.is_none());
let legacy_output = image::load_from_memory(legacy_output.bytes.as_slice())
.expect("legacy output should remain a valid image");
assert_eq!((legacy_output.width(), legacy_output.height()), (64, 64));
assert_eq!((legacy_output.width(), legacy_output.height()), (128, 128));
}
#[test]
@@ -8,8 +8,9 @@
//! This production variant separates the flat image used for grid detection
//! from the straight-RGBA image used for sampling. Soft alpha contributes to
//! per-cell coverage and alpha-weighted RGB, while the delivered PNG uses only
//! binary alpha and is emitted at the detected logical-grid dimensions, with
//! one output pixel per sampled grid cell.
//! binary alpha. Sampling still emits one pixel per grid cell; that logical
//! image is then integer-nearest scaled by a single factor chosen to minimize
//! distance to the source size, preserving the logical aspect ratio.
use std::{error::Error, fmt, io::Cursor, time::Instant};
@@ -150,8 +151,9 @@ impl SnapConfig {
/// `grid_source` supplies the RGB structure used to detect the grid.
/// `rgba_source` supplies straight RGBA used for coverage and color sampling.
/// Both decoded images must have exactly the same dimensions. The returned
/// PNG uses the detected logical-grid dimensions, with one output pixel per
/// sampled grid cell. Its alpha channel contains only `0` or `255`, and fully
/// PNG keeps the logical aspect ratio: one output pixel per sampled grid cell,
/// then a single integer nearest-neighbor factor that minimizes distance to
/// the source size. Its alpha channel contains only `0` or `255`, and fully
/// transparent pixels are canonical `[0, 0, 0, 0]`.
///
/// The deadline is checked before and after non-cooperative codec operations,
@@ -239,8 +241,14 @@ fn snap_pixel_art_with_grid_policy(
config.alpha_threshold,
deadline,
)?;
let scaled = scale_logical_to_nearest_source(
logical,
rgba_image.width(),
rgba_image.height(),
deadline,
)?;
deadline.check("PNG 编码")?;
let encoded = encode_png(logical)?;
let encoded = encode_png(scaled)?;
deadline.check("PNG 编码")?;
Ok(encoded)
}
@@ -318,6 +326,136 @@ fn validate_dimensions(
Ok(())
}
fn scale_logical_to_nearest_source(
logical: RgbaImage,
source_width: u32,
source_height: u32,
deadline: DeadlineGuard,
) -> Result<RgbaImage, PixelArtSnapError> {
deadline.check("整数倍放大")?;
let scale = choose_integer_scale(
source_width,
source_height,
logical.width(),
logical.height(),
);
if scale <= 1 {
return Ok(logical);
}
let output = integer_nearest_scale(&logical, scale, deadline)?;
deadline.check("整数倍放大")?;
Ok(output)
}
fn choose_integer_scale(
source_width: u32,
source_height: u32,
logical_width: u32,
logical_height: u32,
) -> u32 {
if logical_width == 0 || logical_height == 0 {
return 1;
}
let cell_width = u64::from(logical_width);
let cell_height = u64::from(logical_height);
let denominator = cell_width
.saturating_mul(cell_width)
.saturating_add(cell_height.saturating_mul(cell_height));
if denominator == 0 {
return 1;
}
let n_star = (cell_width.saturating_mul(u64::from(source_width))
+ cell_height.saturating_mul(u64::from(source_height))) as f64
/ denominator as f64;
let floor_n = n_star.floor().max(1.0) as u32;
let ceil_n = n_star.ceil().max(1.0) as u32;
let mut chosen = if floor_n == ceil_n {
floor_n
} else if scale_distance_squared(
ceil_n,
logical_width,
logical_height,
source_width,
source_height,
) < scale_distance_squared(
floor_n,
logical_width,
logical_height,
source_width,
source_height,
) {
ceil_n
} else {
floor_n
};
while chosen > 1 && !integer_scale_fits(chosen, logical_width, logical_height) {
chosen -= 1;
}
if integer_scale_fits(chosen, logical_width, logical_height) {
chosen
} else {
1
}
}
fn scale_distance_squared(
scale: u32,
logical_width: u32,
logical_height: u32,
source_width: u32,
source_height: u32,
) -> u128 {
let scale = i128::from(scale);
let delta_width = scale * i128::from(logical_width) - i128::from(source_width);
let delta_height = scale * i128::from(logical_height) - i128::from(source_height);
(delta_width * delta_width + delta_height * delta_height) as u128
}
fn integer_scale_fits(scale: u32, logical_width: u32, logical_height: u32) -> bool {
let Some(width) = logical_width.checked_mul(scale) else {
return false;
};
let Some(height) = logical_height.checked_mul(scale) else {
return false;
};
width <= MAX_IMAGE_DIMENSION
&& height <= MAX_IMAGE_DIMENSION
&& u64::from(width).saturating_mul(u64::from(height)) <= PIXEL_ART_MAX_IMAGE_PIXELS
}
fn integer_nearest_scale(
source: &RgbaImage,
scale: u32,
deadline: DeadlineGuard,
) -> Result<RgbaImage, PixelArtSnapError> {
let output_width = source
.width()
.checked_mul(scale)
.ok_or_else(|| PixelArtSnapError::Processing("整数倍放大后的宽度溢出".to_string()))?;
let output_height = source
.height()
.checked_mul(scale)
.ok_or_else(|| PixelArtSnapError::Processing("整数倍放大后的高度溢出".to_string()))?;
let mut output = RgbaImage::new(output_width, output_height);
let mut pixel_index = 0usize;
for y in 0..source.height() {
for x in 0..source.width() {
deadline.check_iteration(pixel_index, "整数倍放大")?;
pixel_index = pixel_index.saturating_add(1);
let pixel = *source.get_pixel(x, y);
for row in 0..scale {
for column in 0..scale {
output.put_pixel(x * scale + column, y * scale + row, pixel);
}
}
}
}
Ok(output)
}
fn encode_png(image: RgbaImage) -> Result<DownloadedImage, PixelArtSnapError> {
let mut cursor = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image)
@@ -1135,7 +1273,7 @@ mod tests {
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(), (64, 64));
assert_eq!(decode_output(&legacy).dimensions(), (128, 128));
assert!(matches!(strict, PixelArtSnapError::GridNotDetected));
}
@@ -1204,14 +1342,23 @@ mod tests {
)
.expect("pixel snapping should succeed");
let decoded = decode_output(&output);
let mut expected = RgbaImage::new(64, 64);
for y in 0..64 {
for x in 0..64 {
expected.put_pixel(x, y, Rgba([x as u8, y as u8, ((x + y) % 256) as u8, 255]));
let mut expected = RgbaImage::new(128, 128);
for y in 0..128 {
for x in 0..128 {
expected.put_pixel(
x,
y,
Rgba([
(x / 2) as u8,
(y / 2) as u8,
((x / 2 + y / 2) % 256) as u8,
255,
]),
);
}
}
assert_eq!(decoded.dimensions(), (64, 64));
assert_eq!(decoded.dimensions(), (128, 128));
assert_eq!(decoded, expected);
assert_eq!(output.mime_type, "image/png");
assert_eq!(output.extension, "png");
@@ -1230,7 +1377,7 @@ mod tests {
.expect("legacy uniform fallback should emit its logical grid");
let decoded = decode_output(&output);
assert_eq!(decoded.dimensions(), (64, 64));
assert_eq!(decoded.dimensions(), (128, 128));
assert_ne!(decoded.dimensions(), source_dimensions);
assert!(decoded.pixels().all(|pixel| pixel.0 == [30, 80, 140, 255]));
}