From 732fcf0b457739e742078ffd5b4e4bf2d5437be7 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 03:46:03 +0000 Subject: [PATCH 01/17] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=83=8F=E7=B4=A0=E8=89=BA=E6=9C=AF=E5=90=8E?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增确定性像素网格规整模块并保留第三方 MIT 许可证。 为普通图片、角色和图标图集接入像素艺术选项、并发预算和失败降级。 同步前端交互、请求契约、OpenAPI、项目文档与测试。 --- .../genarrative-external-v1.openapi.json | 51 +- .../shared-memory/decision-log.md | 10 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 15 + ...辑器】画板图标素材生成入口设计-2026-06-15.md | 17 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 15 +- server-rs/crates/api-server/src/app.rs | 77 ++ .../api-server/src/editor_agent/tool.rs | 4 + .../crates/api-server/src/editor_project.rs | 791 ++++++++++- .../api-server/src/external_editor_api.rs | 35 +- .../LICENSE.spritefusion-pixel-snapper | 21 + server-rs/crates/platform-image/src/lib.rs | 5 + .../platform-image/src/pixel_art_snapper.rs | 1160 +++++++++++++++++ ...CanvasEditorGenerationIntegration.test.tsx | 11 + .../ImageCanvasEditorModel.test.ts | 32 + .../image-editor/ImageCanvasEditorModel.ts | 9 + .../image-editor/ImageCanvasEditorTypes.ts | 2 + .../ImageCanvasGenerationDialogModel.test.ts | 5 + .../ImageCanvasGenerationDialogModel.ts | 3 + ...eCanvasGenerationImageOptionsView.test.tsx | 62 + .../ImageCanvasGenerationImageOptionsView.tsx | 19 + ...ageCanvasGenerationSubmissionModel.test.ts | 24 + .../ImageCanvasGenerationSubmissionModel.ts | 5 + src/index.css | 32 + .../image-editor/editorProjectClient.test.ts | 4 + .../image-editor/editorProjectClient.ts | 6 + 25 files changed, 2343 insertions(+), 72 deletions(-) create mode 100644 server-rs/crates/platform-image/licenses/LICENSE.spritefusion-pixel-snapper create mode 100644 server-rs/crates/platform-image/src/pixel_art_snapper.rs diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 7d76b8e11..d170c457f 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2561,6 +2561,21 @@ ], "description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。" }, + "style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "examples": [ + "none", + "pixelArt" + ], + "description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 仅支持普通图片(kind 省略)和 character。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" + }, "size": { "type": "string", "description": "兼容旧 size 入参;未传 aspectRatio/imageSize 时生效。", @@ -2585,7 +2600,7 @@ "ui-design", "publication-material" ], - "default": "spec" + "description": "省略时生成普通图片;其它值选择对应的专用生成流程。" }, "model": { "type": "string", @@ -2963,6 +2978,21 @@ ], "description": "纯色抠像背景色。可传画布支持的纯色背景 hex(如 #CFEFFF)指定;传 \"auto\"、null 或省略则由服务端自动决策。" }, + "style": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "examples": [ + "none", + "pixelArt" + ], + "description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" + }, "model": { "type": "string", "default": "gemini-3.1-flash-image-preview" @@ -3176,8 +3206,13 @@ "properties": { "code": { "type": "string", - "const": "postprocess-failed-source-preserved", - "description": "透明背景处理最终失败并保留 provider 原图时的稳定原因码。" + "enum": [ + "postprocess-failed-source-preserved", + "dimension-restore-fallback", + "unsupported-image-style", + "multiple-generation-warnings" + ], + "description": "生成成功但后处理发生非阻断降级时的稳定原因码。" }, "reason": { "type": "string", @@ -3226,7 +3261,7 @@ "type": "null" } ], - "description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。与通用 warning 互斥。" + "description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。透明背景最终失败时不会进入拆分;风格归一化或像素规整产生通用 warning 时,两者可以并存。" }, "prompt": { "type": "string" @@ -3290,14 +3325,8 @@ "type": "null" } ], - "description": "透明背景处理最终失败、provider 原图作为主结果时返回的非阻断告警。与 sliceWarning 互斥。" + "description": "生成成功但风格归一化、尺寸恢复、透明背景处理或像素规整发生非阻断降级时返回。透明背景最终失败时不会进入拆分;其它通用告警可以与 sliceWarning 并存。" } - }, - "not": { - "required": [ - "warning", - "sliceWarning" - ] } }, "EditorCharacterAnimationGenerationRequest": { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index deafcaa38..30c7b019a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4510,3 +4510,13 @@ - 对账边界:保留管理员显式手动对账。owner 始终可用;member 必须单独持有 `profile-wallet-consumption-reconcile` 独立操作权限,任意 Tab 都不隐式授予。`POST /admin/api/profile/users/reconcile-consumption` 经二次确认后调用 runtime service identity 受限 procedure,扫描该用户全部权威流水、比较并校准投影,记录管理员与对账时间。 - 展示边界:现有共享“用户详情”弹窗的钱包区增加“历史花费”,前端只展示 BFF 顶层字段,不自行汇总账单;只有 BFF 返回 `canReconcileConsumption=true` 时展示手动对账按钮。 - 验证方式:SpacetimeDB 钱包聚合测试、api-server / admin-web 定向测试、`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run admin-web:typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发 + +- 契约:普通图片 / 角色共用的图片生成请求和图标图集生成请求增加可选字符串 `style`,当前公开合法值为 `none / pixelArt`。省略、`null`、空字符串和 `none` 归一为内部 `None` 且不告警;未知字符串、或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `pixelArt` 时,按 `None` 继续原管线并返回 `unsupported-image-style` 通用告警;非字符串 JSON 返回 `400`。旧队列 payload 缺少字段时兼容为 `None`。 +- UI 边界:只有普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 显示 `像素艺术` 勾选项;当前选择可进入已有生成器快照和请求 / 队列 payload,但不写入 `generationInputs`、素材元数据或新表。画布 Agent 和其它生成 / 编辑入口不开放该选项。 +- 处理边界:`PixelArt` 由 `platform-image` 的纯同步、纯内存 Rust 模块执行,不运行 Python、不访问 OSS / 数据库 / 画布。普通图片直接使用 provider 图;角色和图标必须等 BgFilter 成功并把 Alpha 回贴到 provider 原尺寸后,以 provider 平底原图分析网格、以透明 RGBA 图采样。固定参数为分析色数 16、Alpha 覆盖阈值 0.375、像素尺寸自动、无固定色板、K-means 最大采样 262144;单格 RGB 按 Alpha 加权,输出 Alpha 只为 0 / 255,逻辑低分辨率结果用 nearest 恢复交付尺寸并跳过 Lanczos。 +- 执行边界:像素规整 CPU 工作使用进程级最大并发 2;取得并发许可的排队时间与实际处理时间共享最多 30 秒预算,同时不得晚于当前请求 deadline,最终取更早者。输入图片任一边上限为 10000 像素、总像素上限为 8294400;超限、排队超时或处理超时均按 best-effort 非致命降级,不持久化部分结果。 +- 去背边界:不修改 BgFilter `flat` 参数、`cross_check`、fallback、Alpha 回贴和默认关闭 despill 的现有行为。BgFilter 最终失败时不运行像素规整;像素规整失败按 best-effort 非致命降级,保留进入该步骤前的图片并通过既有通用 `warning` 完成任务,不退款。 +- 持久化边界:逻辑低分辨率图、像素化前后对比图、预览、诊断和报告一律不持久化;像素模式只替换原本即将上传的最终图片字节。普通图片、角色、图标的 OSS PUT、asset / project resource 和画布 item 数量必须与 `None` 模式完全一致;角色 / 图标最多因复用失败增加一次对已有 provider 对象的 OSS GET,不得增加 PUT、资源类型、画布项、队列类型或 schema 字段。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/openapi/genarrative-external-v1.openapi.json`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 3bff57d46..95a84554e 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -31,6 +31,18 @@ - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 +### 静态图片风格与像素规整边界 + +- 普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 保存选择并随现有请求 / 队列 payload 传递;该字段不写入用户可见 `generationInputs`,也不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。 +- `style` 是可选字符串兼容边界。省略、`null`、空字符串和 `"none"` 统一归一为内部 `None`,不返回告警;`"pixelArt"` 仅允许普通图片(`kind` 省略)与 `kind="character"`,图标图集请求单独允许该值。未知字符串或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `"pixelArt"` 时,按 `None` 完成原管线并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍是畸形请求并返回 `400`。旧 payload 缺少字段时等价于 `None`。 +- `None` 必须保持现有生成、尺寸处理、BgFilter、上传、资源和画布链路不变。`PixelArt` 只增加父流程内的纯内存 Rust 后处理,不启动 Python 或独立服务,也不改变 BgFilter 的 `flat` 参数、Alpha 回贴、`cross_check`、fallback 或默认关闭 despill 的现有行为。 +- 普通图片在 provider 回图后,以同一张图同时作为网格分析源和 RGBA 采样源;角色与图标在 BgFilter 正常成功、现有 Alpha 蒙版回贴到 provider 原尺寸后执行双输入规整,其中网格分析源为带纯色背景的 provider 原图,RGBA 采样源为 Alpha 已回贴的透明图。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、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 色只负责网格识别,不限制最终输出色数。 +- 逻辑低分辨率图只存在于内存,随后使用 nearest 恢复到该任务原有交付尺寸,并直接替换原本即将持久化的最终图片字节。像素模式不得再经过 Lanczos 或其它会重新引入软边的插值。角色和图标应复用 Alpha 回贴阶段已经读取的 provider 原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 +- 像素模式的持久化增量必须为零:普通图片仍只上传原有一张最终主图;角色仍只保留原有 provider 原图与透明主图;图标仍只保留原有 provider 原图、透明图集和实际成功的切片。禁止保存逻辑低分辨率图、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 +- 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 + ### 角色动作帧抠图像素边界 - 图片画布角色动作的 FFmpeg 抽帧在上传 OSS 前转为 RGB8,并按最终帧宽高 contain 到内容尺寸;抠图前不创建最终尺寸画布、不引入 Alpha 通道、不增加 padding。同一个无补边 object key 供 `BgFilter → 阿里云通用抠图 → 本地键色` 三段链路使用。抠图完成后才转为最终目标尺寸 RGBA8,并以 `RGBA(0,0,0,0)` 居中补边。`560×752 → 323×480` 的验收样例中,抠图输入为 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。该规则只作用于图片画布角色动作输入准备,不改变旧动作发布、采样、BgFilter 请求或 OSS 流程。 @@ -95,6 +107,7 @@ - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程先按 provider 原始分辨率保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连);透明处理成功时只重采样透明图的 alpha 蒙版并应用回 provider 原图 RGB,最后把透明主结果归一到统一业务像素;最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并尝试拆分。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 +- `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 @@ -120,6 +133,8 @@ - 拖拽图片或生成占位框接近其它图片 / 生成占位框边缘、中心或等距分布位置时显示吸附线,并保存吸附后的最终布局。 - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图再次改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。 +- 普通图片、角色和图标面板显示 `像素艺术` 勾选项并正确提交 / 恢复 `style: "none" | "pixelArt"`;其它生成或编辑面板不显示该选项。旧 payload、未知字符串、不支持 `kind` 和非字符串输入分别按本方案约定的兼容或错误语义处理。 +- `pixelArt` 输出 Alpha 只包含 `0 / 255`,使用 nearest 恢复交付尺寸且不新增颜色软边;成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item,逻辑低分辨率图不得出现在 OSS 或响应资源快照中。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 画布常用快捷键必须与右上角快捷键弹窗一致;新增快捷键时应同步更新 `ImageCanvasShortcutModel`、快捷键 hook 单测和本方案。输入框、文本域和 contenteditable 聚焦时不得触发画布编辑快捷键。 - 撤销或恢复画布布局时不得覆盖同 ID 生成对象当前的任务生命周期、提示词、参考图和结果;上传持久化延迟回填内部资源 ID 不得把安全移动误判为素材替换。生成结果必须在加入画布前写入生成历史,自动适合视图不得覆盖这条栈顶记录。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index cf6fe7712..176d23f6b 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -2,6 +2,8 @@ 日期:`2026-06-15` +更新时间:`2026-07-28` + ## 背景 图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet;后端去背景正常成功后,再尝试自动拆分为可独立编辑的素材。 @@ -43,6 +45,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"`。 - `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"`。 @@ -57,11 +60,22 @@ <素材描述按中文顿号拼接> ``` +## 像素风格后处理 + +- 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 +- `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。 +- 像素规整位于 BgFilter 正常成功且现有 Alpha 蒙版已经回贴到 provider 原尺寸之后、透明 spritesheet 最终尺寸处理和上传之前。网格分析源使用已有的带纯色背景 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;像素规整成功后才进入原有连通域自动拆分。 +- 首版固定参数为分析色数 `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 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。 +- 逻辑低分辨率图只存在内存,并以 nearest 恢复到图集原有交付尺寸;像素模式不再经过 Lanczos。实现应复用 Alpha 回贴阶段读取的 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 +- 开启或关闭像素风格都保持现有 provider 原图、透明图集和实际成功切片的持久化与画布数量不变。禁止上传逻辑低分辨率图、像素化前后双份图集、预览或诊断图,也不新增 asset kind、项目资源、画布 item、任务类型或数据库字段。 +- 本功能不修改 BgFilter `flat` 调用、`cross_check=off`、fallback、Alpha 回贴或默认关闭 despill 的现状。BgFilter 最终失败时沿用只保留 provider 原图且不拆分的既有收口,像素规整不运行;像素规整自身失败时保留已成功的透明图并继续上传和拆分,通过通用 `warning` 非致命提示,不退款。`sliceWarning` 继续只表达透明图成功后的自动拆分失败,可与风格归一化或像素规整产生的通用 `warning` 并存。 + ## 去背与保存 - 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。 - 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 spritesheet 写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。 -- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 +- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,因为透明背景失败时不会进入拆分,但可与风格归一化或像素规整产生的通用 `warning` 并存。前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 - 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。 - 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。 @@ -78,6 +92,7 @@ - 默认 6 个素材描述会进入 prompt;用户在单个文本框中继续输入时最多解析 100 个素材描述。 - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 +- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,`pixelArt` 在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。 - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。 - 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的按描述命名的独立图标图层;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。 - 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index f53c3d617..069c60404 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-07-21` +更新时间:`2026-07-28` ## 背景 @@ -60,6 +60,18 @@ - `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`。 - `gpt-image-2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/generations` 或 `/v1/images/edits`。K 档按最长边计算,并转换为 provider 可直接生成的合法像素:`1K` 的 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9` 分别为 `1024x1024 / 1024x768 / 1024x688 / 688x1024 / 608x1088 / 1088x608`;`2K` 分别为 `2048x2048 / 2048x1536 / 2048x1376 / 1376x2048 / 1152x2048 / 2048x1152`。其中 9:16 的 1K 尺寸按 provider 最小总像素和 16 对齐约束修正。禁止把 2K 竖图回落为 1K 请求,也禁止在回图后放大伪造所选 K 档。 - 后端如果收到参考图,`nanobanana2` 把参考图作为 `inline_data` 传入原生 `generateContent`;`gpt-image-2` 走带多参考图的图片编辑链路。没有参考图时按所选模型走纯文本生成链路。 + +## 风格与像素规整 + +- 角色面板增加紧凑的 `像素艺术` 勾选项,请求使用可选字符串字段 `style`:未勾选传 `"none"`,勾选传 `"pixelArt"`。该选择可以随现有生成器快照和队列 payload 保存,但不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 +- `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 在 `kind="character"` 时启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。同一图片生成请求 DTO 被其它 `kind` 复用时,只有普通图片和 `character` 支持 `"pixelArt"`,其它 `kind` 收到该值也按不支持风格降级。 +- 像素规整位于 BgFilter 正常成功且现有 Alpha 蒙版已经回贴到 provider 原尺寸之后、最终尺寸处理和透明主图上传之前。网格分析源使用已有的带纯色背景 provider 原图,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 已回贴的透明图并走非致命降级。 +- 逻辑低分辨率图只存在内存,并以 nearest 恢复到角色任务原有交付尺寸,像素模式不再经过 Lanczos。实现应复用 Alpha 回贴阶段读取的 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 +- 开启或关闭像素风格都保持现有 provider 原图与透明主图两份产物、项目资源和画布图层数量不变。禁止上传逻辑低分辨率图、像素化前后双份主图、预览或诊断图,也不新增 asset kind、画布 item 或任务类型。 +- 本功能不修改 BgFilter `flat` 调用、`cross_check=on`、fallback、Alpha 回贴或默认关闭 despill 的现状。BgFilter 最终失败时沿用只保留 provider 原图的既有收口且不运行像素规整;像素规整自身失败时保留已成功的透明图并继续原有持久化,通过通用 `warning` 非致命提示,不退款。 + - `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架: ```text @@ -106,6 +118,7 @@ - `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。 - 上传常规参考图后缩略图右下角显示序号。 - 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`screenColor`、`aspectRatio` 和 `imageSize`。 +- 角色面板可选择 `style: "none" | "pixelArt"`;`none` 的处理路径和产物保持不变,`pixelArt` 在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。 - 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。 - 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index ef49e1123..bb788cedc 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1912,6 +1912,83 @@ mod tests { ); } + #[tokio::test] + async fn editor_pixel_art_style_wrong_types_return_bad_request() { + 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, "13800138228", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_style_body"); + let app = build_router(state); + let requests = [ + ( + "/api/editor/images/generations", + serde_json::json!({ + "prompt": "生成像素图片", + "style": {"unexpected": true}, + }), + ), + ( + "/api/editor/icon-spritesheets/generations", + serde_json::json!({ + "referenceImageSrc": "/generated-images/editor/icon-spec.png", + "iconDescriptions": ["宝箱"], + "style": ["pixelArt"], + }), + ), + ]; + + for (path, request_body) in requests { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .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, + "{path} should normalize JSON data errors to 400" + ); + } + } + + #[tokio::test] + async fn editor_generation_json_validation_preserves_unsupported_media_type() { + 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, "13800138229", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_style_media_type"); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/images/generations") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(r#"{"prompt":"生成图片","style":"pixelArt"}"#)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + #[tokio::test] async fn editor_image_edit_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index b1c21b879..a14b96efd 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -330,6 +330,7 @@ impl EditorAgentTool for GenerateImageTool { prompt: args.prompt, size: None, kind: None, + style: None, model: Some(args.model), screen_color: None, seg_model: None, @@ -437,6 +438,7 @@ impl EditorAgentTool for GenerateCharacterTool { prompt: args.prompt, size: None, kind: Some("character".to_string()), + style: None, model: Some(args.model), screen_color: Some("auto".to_string()), seg_model: Some("birefnet".to_string()), @@ -546,6 +548,7 @@ impl EditorAgentTool for GenerateUiDesignTool { prompt: args.prompt, size: None, kind: Some("ui-design".to_string()), + style: None, model: Some(args.model), screen_color: None, seg_model: None, @@ -799,6 +802,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { reference_image_src, reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions, + style: None, model: Some(args.model), screen_color: Some("auto".to_string()), seg_model: Some("birefnet".to_string()), diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5f3c0c041..3fccec8de 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2,12 +2,13 @@ use std::{ borrow::Cow, collections::BTreeMap, io::Cursor, + sync::{Arc, LazyLock}, time::{Duration, Instant}, }; use axum::{ Json, - extract::{Extension, Path, Query, State}, + extract::{Extension, Path, Query, State, rejection::JsonRejection}, http::StatusCode, }; use module_assets::{ @@ -123,6 +124,15 @@ const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-con const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; +const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-image-style"; +const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings"; +const EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY: usize = 2; +const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); +static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock> = LazyLock::new(|| { + Arc::new(tokio::sync::Semaphore::new( + EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, + )) +}); const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; @@ -238,6 +248,8 @@ pub struct EditorImageGenerationRequest { pub(crate) prompt: String, pub(crate) size: Option, pub(crate) kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) style: Option, pub(crate) model: Option, pub(crate) screen_color: Option, pub(crate) seg_model: Option, @@ -294,6 +306,8 @@ pub struct EditorIconSpritesheetGenerationRequest { pub(crate) reference_image_src: String, pub(crate) reference_image_srcs: Option>, pub(crate) icon_descriptions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) style: Option, pub(crate) model: Option, pub(crate) screen_color: Option, pub(crate) seg_model: Option, @@ -591,6 +605,13 @@ struct EditorGeneratedImageStorageProfile { slot: &'static str, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum EditorImageGenerationStyle { + #[default] + None, + PixelArt, +} + pub(crate) struct EditorCanvasGeneratedLayerInput { pub(crate) layer_id: String, pub(crate) resource_id: String, @@ -632,6 +653,10 @@ fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGeneration } } +fn editor_pixel_art_fallback_warning() -> EditorGenerationWarningResponse { + editor_postprocess_fallback_warning("像素规整未完成,已保留原始生成结果。") +} + fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse { let reason = error .details() @@ -645,6 +670,68 @@ fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarning } } +fn editor_unsupported_image_style_warning() -> EditorGenerationWarningResponse { + EditorGenerationWarningResponse { + code: EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE, + reason: "不支持的图片风格,已按无风格处理。".to_string(), + } +} + +fn merge_editor_generation_warnings( + current: Option, + next: Option, +) -> Option { + match (current, next) { + (None, None) => None, + (Some(warning), None) | (None, Some(warning)) => Some(warning), + (Some(current), Some(next)) if current.reason == next.reason => Some(current), + (Some(current), Some(next)) => Some(EditorGenerationWarningResponse { + code: if current.code == next.code { + current.code + } else { + EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE + }, + reason: format!("{} {}", current.reason, next.reason), + }), + } +} + +fn normalize_editor_image_generation_style( + value: Option<&str>, + pixel_art_supported: bool, +) -> ( + EditorImageGenerationStyle, + Option, +) { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return (EditorImageGenerationStyle::None, None); + }; + match value { + "none" => (EditorImageGenerationStyle::None, None), + "pixelArt" if pixel_art_supported => (EditorImageGenerationStyle::PixelArt, None), + _ => ( + EditorImageGenerationStyle::None, + Some(editor_unsupported_image_style_warning()), + ), + } +} + +pub(crate) fn parse_editor_generation_json_payload( + payload: Result, JsonRejection>, +) -> Result, AppError> { + payload.map_err(|error| { + let status = if error.status() == StatusCode::UNPROCESSABLE_ENTITY { + StatusCode::BAD_REQUEST + } else { + error.status() + }; + AppError::from_status(status).with_details(json!({ + "provider": "editor-generation-request", + "message": error.body_text(), + })) + }) +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationResponse { @@ -1478,8 +1565,9 @@ pub async fn generate_editor_image( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(payload): Json, + payload: Result, JsonRejection>, ) -> Result, AppError> { + let Json(payload) = parse_editor_generation_json_payload(payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { ensure_editor_reference_image_sources_are_stable( @@ -1566,6 +1654,9 @@ pub(crate) async fn generate_editor_image_for_owner( let normalized_kind = payload.kind.as_deref().map(str::trim); let is_character_generation = matches!(normalized_kind, Some("character")); + let pixel_art_supported = matches!(normalized_kind, None | Some("") | Some("character")); + let (image_style, mut generation_warning) = + normalize_editor_image_generation_style(payload.style.as_deref(), pixel_art_supported); // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要、但随后会被 payload 消费掉的输入。 let requested_screen_color = payload.screen_color.clone(); @@ -1797,20 +1888,25 @@ pub(crate) async fn generate_editor_image_for_owner( // 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回 // 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一, // 保证不同模型的完成图与生成前占位标注一致。 - let (restored_image, dimension_restore_error) = if is_character_generation { - // 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存; - // 透明主结果会在后处理完成后再归一到画布业务规格。 - (image, None) - } else { - restore_editor_generated_image_output_dimensions_or_original( - image, - generation_options.model, - image_size.as_ref(), - ) - }; - let mut dimension_warning = dimension_restore_error - .as_ref() - .map(editor_dimension_restore_warning); + let (restored_image, dimension_restore_error) = + if is_character_generation || image_style == EditorImageGenerationStyle::PixelArt { + // 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存; + // 透明主结果会在后处理完成后再归一到画布业务规格。像素模式同样必须 + // 先完成逻辑像素规整,再用 nearest 恢复交付尺寸,不能提前走 Lanczos。 + (image, None) + } else { + restore_editor_generated_image_output_dimensions_or_original( + image, + generation_options.model, + image_size.as_ref(), + ) + }; + generation_warning = merge_editor_generation_warnings( + generation_warning, + dimension_restore_error + .as_ref() + .map(editor_dimension_restore_warning), + ); if let Some(error) = dimension_restore_error { tracing::warn!( task_id = %generated.task_id, @@ -1831,6 +1927,7 @@ pub(crate) async fn generate_editor_image_for_owner( let mut output_model = generation_options.model.to_string(); let mut output_provider = "VectorEngine".to_string(); let mut output_generation_inputs = generation_inputs.clone(); + let mut pixel_art_applied = false; let source_record = if is_character_generation { let source_persisted = persist_editor_provider_source_image( state, @@ -1930,23 +2027,41 @@ pub(crate) async fn generate_editor_image_for_owner( resource: source_record.resource, asset: source_record.asset, project: completed_project, - warning: Some(editor_postprocess_fallback_warning( - "生成任务成功,后处理失败。", - )), + warning: merge_editor_generation_warnings( + generation_warning, + Some(editor_postprocess_fallback_warning( + "生成任务成功,后处理失败。", + )), + ), }, )); } }; let removal_provider = removal.provider; - let (restored_removal_image, postprocess_dimension_error) = - apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( + let (restored_removal_image, postprocess_dimension_error, pixel_art_error) = if image_style + == EditorImageGenerationStyle::PixelArt + { + apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state, source_object_key.as_str(), - delivery_width, - delivery_height, removal.image, + request_context.external_call_deadline(), ) - .await; + .await + } else { + let (image, error) = + apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( + state, + source_object_key.as_str(), + delivery_width, + delivery_height, + removal.image, + ) + .await; + (image, error, None) + }; + pixel_art_applied = + image_style == EditorImageGenerationStyle::PixelArt && pixel_art_error.is_none(); if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, @@ -1959,6 +2074,18 @@ pub(crate) async fn generate_editor_image_for_owner( "角色透明图尺寸恢复失败,保留去背景服务原始输出" ); } + if let Some(error) = pixel_art_error { + tracing::warn!( + task_id = %generated.task_id, + provider = removal_provider, + error = %error, + "角色像素规整失败,保留透明后处理图" + ); + generation_warning = merge_editor_generation_warnings( + generation_warning, + Some(editor_pixel_art_fallback_warning()), + ); + } image = restored_removal_image; output_prompt = "去除纯色背景".to_string(); output_actual_prompt = None; @@ -1971,15 +2098,42 @@ pub(crate) async fn generate_editor_image_for_owner( None }; - if is_character_generation { + 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()) + .await; + image = pixel_art_image; + pixel_art_applied = pixel_art_error.is_none(); + if let Some(error) = pixel_art_error { + tracing::warn!( + task_id = %generated.task_id, + error = %error, + "画板图片像素规整失败,保留 provider 图" + ); + generation_warning = merge_editor_generation_warnings( + generation_warning, + Some(editor_pixel_art_fallback_warning()), + ); + } + } + + if is_character_generation || image_style == EditorImageGenerationStyle::PixelArt { let (restored_image, dimension_restore_error) = - restore_editor_generated_image_output_dimensions_or_original( + restore_editor_generated_image_output_dimensions_or_original_with_filter( image, generation_options.model, image_size.as_ref(), + if pixel_art_applied { + image::imageops::FilterType::Nearest + } else { + image::imageops::FilterType::Lanczos3 + }, ); if let Some(error) = dimension_restore_error { - dimension_warning = Some(editor_dimension_restore_warning(&error)); + generation_warning = merge_editor_generation_warnings( + generation_warning, + Some(editor_dimension_restore_warning(&error)), + ); tracing::warn!( task_id = %generated.task_id, provider_width, @@ -2084,7 +2238,7 @@ pub(crate) async fn generate_editor_image_for_owner( resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, - warning: dimension_warning, + warning: generation_warning, }, )) } @@ -2802,7 +2956,26 @@ fn restore_editor_generated_image_output_dimensions_or_original( model: &str, target_size: &str, ) -> (DownloadedOpenAiImage, Option) { - match restore_editor_generated_image_output_dimensions(&output, model, target_size) { + restore_editor_generated_image_output_dimensions_or_original_with_filter( + output, + model, + target_size, + image::imageops::FilterType::Lanczos3, + ) +} + +fn restore_editor_generated_image_output_dimensions_or_original_with_filter( + output: DownloadedOpenAiImage, + model: &str, + target_size: &str, + filter: image::imageops::FilterType, +) -> (DownloadedOpenAiImage, Option) { + match restore_editor_generated_image_output_dimensions_with_filter( + &output, + model, + target_size, + filter, + ) { Ok(Some(restored)) => (restored, None), Ok(None) => (output, None), Err(error) => (output, Some(error)), @@ -2812,6 +2985,35 @@ fn restore_editor_generated_image_output_dimensions_or_original( fn apply_editor_postprocessed_alpha_to_provider_source( provider_source: &DownloadedOpenAiImage, postprocessed: &DownloadedOpenAiImage, +) -> Result, AppError> { + apply_editor_postprocessed_alpha_to_provider_source_with_policy( + provider_source, + postprocessed, + false, + ) +} + +fn apply_editor_postprocessed_alpha_using_provider_rgb( + provider_source: &DownloadedOpenAiImage, + postprocessed: &DownloadedOpenAiImage, +) -> Result { + apply_editor_postprocessed_alpha_to_provider_source_with_policy( + provider_source, + postprocessed, + true, + )? + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-image-postprocess", + "message": "像素规整输入未能组合 provider RGB 与透明后处理 Alpha", + })) + }) +} + +fn apply_editor_postprocessed_alpha_to_provider_source_with_policy( + provider_source: &DownloadedOpenAiImage, + postprocessed: &DownloadedOpenAiImage, + always_use_provider_rgb: bool, ) -> Result, AppError> { let provider_source = image::load_from_memory(provider_source.bytes.as_slice()).map_err(|error| { @@ -2827,9 +3029,9 @@ fn apply_editor_postprocessed_alpha_to_provider_source( "message": format!("透明后处理图不是有效图片:{error}"), })) })?; - if postprocessed.width() == provider_source.width() - && postprocessed.height() == provider_source.height() - { + let dimensions_match = postprocessed.width() == provider_source.width() + && postprocessed.height() == provider_source.height(); + if dimensions_match && !always_use_provider_rgb { return Ok(None); } @@ -2839,12 +3041,16 @@ fn apply_editor_postprocessed_alpha_to_provider_source( let alpha = image::GrayImage::from_fn(postprocessed.width(), postprocessed.height(), |x, y| { image::Luma([postprocessed.get_pixel(x, y).0[3]]) }); - let alpha = image::imageops::resize( - &alpha, - provider_source.width(), - provider_source.height(), - image::imageops::FilterType::Lanczos3, - ); + let alpha = if dimensions_match { + alpha + } else { + image::imageops::resize( + &alpha, + provider_source.width(), + provider_source.height(), + image::imageops::FilterType::Lanczos3, + ) + }; let mut restored = provider_source.to_rgba8(); for (x, y, pixel) in restored.enumerate_pixels_mut() { pixel.0[3] = alpha.get_pixel(x, y).0[0]; @@ -2908,10 +3114,207 @@ async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_orig } } +fn take_arc_downloaded_image(image: Arc) -> DownloadedOpenAiImage { + Arc::try_unwrap(image).unwrap_or_else(|image| image.as_ref().clone()) +} + +async fn acquire_editor_pixel_art_cpu_permit( + processing_deadline: Instant, +) -> Result { + if Instant::now() >= processing_deadline { + return Err("像素规整处理预算已耗尽,已保留原始生成结果。".to_string()); + } + match tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + Arc::clone(&*EDITOR_PIXEL_ART_CPU_LIMITER).acquire_owned(), + ) + .await + { + Ok(Ok(permit)) => Ok(permit), + Ok(Err(error)) => Err(format!("像素规整 CPU 并发门限不可用:{error}")), + Err(_) => Err("像素规整等待 CPU 时处理预算已耗尽,已保留原始生成结果。".to_string()), + } +} + +async fn snap_editor_pixel_art_or_original( + rgba_source: DownloadedOpenAiImage, + request_deadline: Option, +) -> (DownloadedOpenAiImage, Option) { + let processing_deadline = + resolve_editor_pixel_art_processing_deadline(Instant::now(), request_deadline); + if Instant::now() >= processing_deadline { + return ( + rgba_source, + Some("像素规整处理预算已耗尽,已保留原始生成结果。".to_string()), + ); + } + let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await { + Ok(permit) => permit, + Err(error) => return (rgba_source, Some(error)), + }; + + let rgba_source = Arc::new(rgba_source); + let worker_rgba_source = Arc::clone(&rgba_source); + let worker = tokio::task::spawn_blocking(move || { + // 中文注释:permit 必须由 blocking 闭包持有,而不是只保护 spawn; + // 即使上层 future 因 worker deadline 被取消,仍会限制尚未结束的 CPU 任务数量。 + let _permit = permit; + platform_image::snap_pixel_art_with_deadline( + worker_rgba_source.as_ref(), + worker_rgba_source.as_ref(), + Some(processing_deadline), + ) + }); + let result = + tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await; + match result { + Ok(Ok(Ok(image))) => (image, None), + Ok(Ok(Err(error))) => ( + take_arc_downloaded_image(rgba_source), + Some(error.to_string()), + ), + Ok(Err(error)) => ( + take_arc_downloaded_image(rgba_source), + Some(format!("像素规整工作线程异常:{error}")), + ), + Err(_) => ( + take_arc_downloaded_image(rgba_source), + Some("像素规整处理超时,已保留原始生成结果。".to_string()), + ), + } +} + +fn resolve_editor_pixel_art_processing_deadline( + started_at: Instant, + request_deadline: Option, +) -> Instant { + let local_deadline = started_at + .checked_add(EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION) + .unwrap_or(started_at); + request_deadline + .map(|request_deadline| request_deadline.min(local_deadline)) + .unwrap_or(local_deadline) +} + +async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( + state: &AppState, + provider_source_object_key: &str, + postprocessed: DownloadedOpenAiImage, + request_deadline: Option, +) -> (DownloadedOpenAiImage, Option, Option) { + let processing_deadline = + resolve_editor_pixel_art_processing_deadline(Instant::now(), request_deadline); + // 中文注释:像素模式必须用 provider 平底原图找网格。原图已经在调用 + // BgFilter 前持久化并释放,因此这里只回读既有对象一次;这次回读同时承担 + // 尺寸漂移时的 alpha 回贴,禁止为两个步骤分别发起 OSS GET。 + if Instant::now() >= processing_deadline { + return ( + postprocessed, + None, + Some("像素规整处理预算已耗尽,已保留透明后处理图。".to_string()), + ); + } + let provider_source = match tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + download_editor_persisted_image_object(state, provider_source_object_key), + ) + .await + { + Ok(Ok(provider_source)) => provider_source, + Ok(Err(error)) => { + let reason = error.body_text(); + return (postprocessed, None, Some(reason)); + } + Err(_) => { + return ( + postprocessed, + None, + Some("像素规整读取 provider 原图超时,已保留透明后处理图。".to_string()), + ); + } + }; + let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await { + Ok(permit) => permit, + Err(error) => return (postprocessed, None, Some(error)), + }; + + let provider_source = Arc::new(provider_source); + let postprocessed = Arc::new(postprocessed); + let worker_provider_source = Arc::clone(&provider_source); + let worker_postprocessed = Arc::clone(&postprocessed); + let worker = tokio::task::spawn_blocking(move || { + // 中文注释:Alpha 合成和网格规整都属于像素 CPU 工作,必须在同一个 permit + // 与同一个绝对 deadline 内完成,避免在 Tokio worker 上无界并发解码/编码。 + let _permit = permit; + if Instant::now() >= processing_deadline { + return Err(( + None, + None, + "像素规整处理预算已耗尽,已保留透明后处理图。".to_string(), + )); + } + let rgba_source = match apply_editor_postprocessed_alpha_using_provider_rgb( + worker_provider_source.as_ref(), + worker_postprocessed.as_ref(), + ) { + Ok(rgba_source) => rgba_source, + Err(error) => { + return Err(( + None, + Some(error), + "像素规整输入准备失败,已保留透明后处理图。".to_string(), + )); + } + }; + match platform_image::snap_pixel_art_with_deadline( + worker_provider_source.as_ref(), + &rgba_source, + Some(processing_deadline), + ) { + Ok(image) => Ok(image), + Err(error) => Err((Some(rgba_source), None, error.to_string())), + } + }); + let result = + tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await; + match result { + Ok(Ok(Ok(image))) => (image, None, None), + Ok(Ok(Err((fallback, preparation_error, error)))) => ( + fallback.unwrap_or_else(|| take_arc_downloaded_image(postprocessed)), + preparation_error, + Some(error), + ), + Ok(Err(error)) => ( + take_arc_downloaded_image(postprocessed), + None, + Some(format!("像素规整工作线程异常:{error}")), + ), + Err(_) => ( + take_arc_downloaded_image(postprocessed), + None, + Some("像素规整处理超时,已保留透明后处理图。".to_string()), + ), + } +} + fn restore_editor_generated_image_output_dimensions( output: &DownloadedOpenAiImage, model: &str, target_size: &str, +) -> Result, AppError> { + restore_editor_generated_image_output_dimensions_with_filter( + output, + model, + target_size, + image::imageops::FilterType::Lanczos3, + ) +} + +fn restore_editor_generated_image_output_dimensions_with_filter( + output: &DownloadedOpenAiImage, + model: &str, + target_size: &str, + filter: image::imageops::FilterType, ) -> Result, AppError> { let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ @@ -2948,11 +3351,7 @@ fn restore_editor_generated_image_output_dimensions( model, )?; - let restored = decoded.resize_to_fill( - target_width, - target_height, - image::imageops::FilterType::Lanczos3, - ); + let restored = decoded.resize_to_fill(target_width, target_height, filter); Ok(Some(DownloadedOpenAiImage { bytes: encode_editor_image_edit_png( restored, @@ -3907,8 +4306,9 @@ pub async fn generate_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(payload): Json, + payload: Result, JsonRejection>, ) -> Result, AppError> { + let Json(payload) = parse_editor_generation_json_payload(payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { ensure_editor_reference_image_source_is_stable( @@ -3980,6 +4380,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "图标素材参考图", )?; let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; + let (image_style, mut generation_warning) = + normalize_editor_image_generation_style(payload.style.as_deref(), true); // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 let requested_screen_color = payload.screen_color.clone(); @@ -4216,23 +4618,39 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_resource: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, - warning: Some(editor_postprocess_fallback_warning( - "生成任务成功,后处理失败。", - )), + warning: merge_editor_generation_warnings( + generation_warning, + Some(editor_postprocess_fallback_warning( + "生成任务成功,后处理失败。", + )), + ), }, )); } }; let removal_provider = removal.provider; - let (image, postprocess_dimension_error) = - apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( + let (image, postprocess_dimension_error, pixel_art_error) = if image_style + == EditorImageGenerationStyle::PixelArt + { + apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state, source_object_key.as_str(), - source_width, - source_height, removal.image, + request_context.external_call_deadline(), ) - .await; + .await + } else { + let (image, error) = + apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( + state, + source_object_key.as_str(), + source_width, + source_height, + removal.image, + ) + .await; + (image, error, None) + }; if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, @@ -4243,6 +4661,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "图标透明图蒙版尺寸恢复失败,保留去背景服务原始输出" ); } + if let Some(error) = pixel_art_error { + tracing::warn!( + task_id = %generated.task_id, + provider = removal_provider, + error = %error, + "图标图集像素规整失败,保留透明后处理图" + ); + generation_warning = merge_editor_generation_warnings( + generation_warning, + Some(editor_pixel_art_fallback_warning()), + ); + } let matting_generation_inputs = build_editor_derived_asset_generation_inputs( "图标图集抠图", &removal.model, @@ -4414,7 +4844,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_resource: spritesheet_record.resource, spritesheet_asset: spritesheet_record.asset, project: completed_project, - warning: None, + warning: generation_warning, }, )) } @@ -8632,6 +9062,114 @@ mod tests { assert_eq!(restored.extension, "png"); } + #[test] + fn pixel_art_sampling_uses_provider_rgb_even_when_bgfilter_dimensions_match() { + let provider_source = image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 10, 20, 255])); + let mut provider_source_bytes = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(provider_source) + .write_to(&mut provider_source_bytes, image::ImageFormat::Png) + .expect("provider test image should encode"); + let provider_source = DownloadedOpenAiImage { + bytes: provider_source_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let mut postprocessed = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 240, 128])); + postprocessed.put_pixel(0, 0, image::Rgba([9, 8, 230, 0])); + postprocessed.put_pixel(1, 1, image::Rgba([7, 6, 220, 255])); + let mut postprocessed_bytes = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(postprocessed) + .write_to(&mut postprocessed_bytes, image::ImageFormat::Png) + .expect("BgFilter test image should encode"); + let postprocessed = DownloadedOpenAiImage { + bytes: postprocessed_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + assert!( + apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) + .expect("legacy same-size alpha path should remain valid") + .is_none() + ); + let combined = + apply_editor_postprocessed_alpha_using_provider_rgb(&provider_source, &postprocessed) + .expect("pixel-art RGBA source should combine"); + let combined = image::load_from_memory(combined.bytes.as_slice()) + .expect("combined PNG should decode") + .to_rgba8(); + + assert_eq!(combined.get_pixel(0, 0).0, [200, 10, 20, 0]); + assert_eq!(combined.get_pixel(0, 1).0, [200, 10, 20, 128]); + assert_eq!(combined.get_pixel(1, 1).0, [200, 10, 20, 255]); + } + + #[tokio::test] + async fn pixel_art_expired_budget_returns_original_without_entering_cpu_work() { + let original = DownloadedOpenAiImage { + bytes: vec![1, 2, 3, 4], + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let expired = Instant::now() + .checked_sub(Duration::from_millis(1)) + .expect("expired test deadline should be representable"); + + let (fallback, error) = + snap_editor_pixel_art_or_original(original.clone(), Some(expired)).await; + + assert_eq!(fallback.bytes, original.bytes); + assert!( + error + .expect("expired budget should warn") + .contains("预算已耗尽") + ); + } + + #[test] + fn pixel_art_processing_deadline_uses_earlier_local_or_request_budget() { + assert_eq!(EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, 2); + assert_eq!( + EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION, + Duration::from_secs(30) + ); + + let started_at = Instant::now(); + let local_deadline = resolve_editor_pixel_art_processing_deadline(started_at, None); + assert_eq!( + local_deadline.duration_since(started_at), + EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION + ); + + let request_deadline = started_at + Duration::from_secs(5); + assert_eq!( + resolve_editor_pixel_art_processing_deadline(started_at, Some(request_deadline)), + request_deadline + ); + } + + #[test] + fn pixel_art_provider_input_prep_shares_deadline_and_cpu_permit() { + let source = include_str!("editor_project.rs"); + assert_function_contains_in_order( + source, + "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", + "fn restore_editor_generated_image_output_dimensions", + &[ + "resolve_editor_pixel_art_processing_deadline(Instant::now(), request_deadline)", + "tokio::time::timeout_at(", + "download_editor_persisted_image_object", + "acquire_editor_pixel_art_cpu_permit(processing_deadline)", + "tokio::task::spawn_blocking", + "let _permit = permit", + "if Instant::now() >= processing_deadline", + "apply_editor_postprocessed_alpha_using_provider_rgb", + "platform_image::snap_pixel_art_with_deadline", + ], + ); + } + #[test] fn publication_material_generation_restores_provider_output_to_workflow_dimensions() { let image = image::DynamicImage::new_rgba8(944, 704); @@ -10305,6 +10843,80 @@ mod tests { assert_eq!(request.prompt, "生成图片"); assert_eq!(request.screen_color.as_deref(), Some("#CFEFFF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); + assert_eq!(request.style, None); + } + + #[test] + fn editor_image_generation_style_is_tolerant_for_unknown_strings() { + for value in [None, Some(""), Some(" "), Some("none")] { + let (style, warning) = normalize_editor_image_generation_style(value, true); + assert_eq!(style, EditorImageGenerationStyle::None); + assert!(warning.is_none()); + } + + let (style, warning) = normalize_editor_image_generation_style(Some("pixelArt"), true); + assert_eq!(style, EditorImageGenerationStyle::PixelArt); + assert!(warning.is_none()); + + for (value, pixel_art_supported) in [("futureStyle", true), ("pixelArt", false)] { + let (style, warning) = + normalize_editor_image_generation_style(Some(value), pixel_art_supported); + assert_eq!(style, EditorImageGenerationStyle::None); + let warning = warning.expect("unsupported style should warn"); + assert_eq!( + warning.code, + EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE + ); + assert!(warning.reason.contains("已按无风格处理")); + } + } + + #[test] + fn editor_image_generation_style_queue_json_round_trips_and_rejects_wrong_types() { + let request: EditorImageGenerationRequest = serde_json::from_value(json!({ + "prompt": "生成像素角色", + "kind": "character", + "style": "pixelArt" + })) + .expect("pixel art request should deserialize"); + let queued_json = serde_json::to_string(&request).expect("request should serialize"); + let restored: EditorImageGenerationRequest = + serde_json::from_str(queued_json.as_str()).expect("queued request should deserialize"); + assert_eq!(restored.style.as_deref(), Some("pixelArt")); + + let legacy: EditorImageGenerationRequest = serde_json::from_value(json!({ + "prompt": "旧请求" + })) + .expect("legacy request should remain compatible"); + assert_eq!(legacy.style, None); + assert!( + serde_json::to_value(&legacy) + .expect("legacy request should serialize") + .get("style") + .is_none() + ); + + assert!( + serde_json::from_value::(json!({ + "prompt": "错误请求", + "style": {} + })) + .is_err() + ); + } + + #[test] + fn editor_generation_warnings_merge_without_changing_the_response_shape() { + let merged = merge_editor_generation_warnings( + Some(editor_unsupported_image_style_warning()), + Some(editor_postprocess_fallback_warning( + "生成任务成功,后处理失败。", + )), + ) + .expect("two warnings should merge"); + assert_eq!(merged.code, EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE); + assert!(merged.reason.contains("已按无风格处理")); + assert!(merged.reason.contains("后处理失败")); } #[test] @@ -10499,6 +11111,20 @@ mod tests { assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); assert_eq!(request.asset_label.as_deref(), Some(" 冒险界面图标 ")); + assert_eq!(request.style, None); + + let pixel_request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ + "referenceImageSrc": "/generated-images/editor/spec.png", + "iconDescriptions": ["返回按钮"], + "style": "pixelArt" + })) + .expect("pixel icon request should deserialize"); + let queued_json = + serde_json::to_string(&pixel_request).expect("pixel icon request should serialize"); + let restored: EditorIconSpritesheetGenerationRequest = + serde_json::from_str(queued_json.as_str()) + .expect("queued pixel icon request should deserialize"); + assert_eq!(restored.style.as_deref(), Some("pixelArt")); } #[test] @@ -11189,6 +11815,12 @@ mod tests { "pub(crate) fn editor_project_payload_from_record", ), ] { + let warning_assignment = if start.contains("extract_editor_ui_design_assets_for_owner") + { + "warning: Some(" + } else { + "warning: merge_editor_generation_warnings(" + }; assert_function_contains_in_order( source, start, @@ -11200,7 +11832,8 @@ mod tests { "Err(error)", "complete_editor_canvas_generation", "source_record.resource.as_ref()", - "warning: Some(editor_postprocess_fallback_warning", + warning_assignment, + "editor_postprocess_fallback_warning(", ], ); @@ -11284,7 +11917,8 @@ mod tests { "complete_editor_canvas_generation", "source_record.resource.as_ref()", "return Ok(json_success_body", - "warning: Some(editor_postprocess_fallback_warning", + "warning: merge_editor_generation_warnings(", + "editor_postprocess_fallback_warning(", ] { assert!( fallback.contains(snippet), @@ -11480,6 +12114,53 @@ mod tests { "persist_editor_generated_image(", 1, ); + assert_function_occurrence_count( + source, + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn split_editor_icon_spritesheet", + "persist_editor_generated_image(", + 1, + ); + assert_function_occurrence_count( + source, + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn split_editor_icon_spritesheet", + "persist_editor_spritesheet_slices(", + 1, + ); + assert_function_not_contains( + source, + "async fn snap_editor_pixel_art_or_original", + "fn restore_editor_generated_image_output_dimensions", + &[ + "persist_editor_generated_image", + "persist_editor_generated_asset", + "persist_editor_spritesheet_slices", + "complete_editor_canvas_generation", + ], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_image_for_owner", + "fn normalize_editor_image_generation_size", + &[ + "normalize_editor_image_generation_style", + "snap_editor_pixel_art_or_original", + "persist_editor_generated_image", + ], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn split_editor_icon_spritesheet", + &[ + "normalize_editor_image_generation_style", + "remove_editor_generated_screen_background_with_bgfilter", + "apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", + "persist_editor_generated_image", + "persist_editor_spritesheet_slices", + ], + ); assert_function_contains_in_order( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index ee3f93733..0f2e9ed0f 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -32,8 +32,8 @@ use crate::{ editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner, generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, - save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata, - serialize_editor_layers, + parse_editor_generation_json_payload, save_editor_project_layout_with_revision_and_get, + serialize_editor_asset_metadata, serialize_editor_layers, }, external_api_auth::ExternalApiPrincipal, http_error::AppError, @@ -623,8 +623,9 @@ pub async fn generate_external_editor_image( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, - Json(payload): Json, + payload: Result, JsonRejection>, ) -> Result, AppError> { + let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; generate_editor_image_for_owner( &state, @@ -655,8 +656,9 @@ pub async fn generate_external_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, - Json(payload): Json, + payload: Result, JsonRejection>, ) -> Result, AppError> { + let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; generate_editor_icon_spritesheet_for_owner( &state, @@ -868,6 +870,16 @@ mod tests { .get("priceMudPoints") .is_none() ); + let image_style_schema = + &parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["style"]; + assert_eq!(image_style_schema["anyOf"][0]["type"], "string"); + assert!(image_style_schema["anyOf"][0].get("enum").is_none()); + assert_eq!(image_style_schema["examples"], json!(["none", "pixelArt"])); + assert!( + parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"] + .get("default") + .is_none() + ); assert_eq!( parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"], "array" @@ -901,6 +913,11 @@ mod tests { .get("/api/external/v1/editor/icon-spritesheets/generations") .is_some() ); + let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] + ["properties"]["style"]; + assert_eq!(icon_style_schema["anyOf"][0]["type"], "string"); + assert!(icon_style_schema["anyOf"][0].get("enum").is_none()); + assert_eq!(icon_style_schema["examples"], json!(["none", "pixelArt"])); assert_eq!( parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"] ["sliceWarning"]["anyOf"][0]["$ref"], @@ -920,6 +937,15 @@ mod tests { parsed["components"]["schemas"]["EditorGenerationWarning"]["required"], json!(["code", "reason"]) ); + assert_eq!( + parsed["components"]["schemas"]["EditorGenerationWarning"]["properties"]["code"]["enum"], + json!([ + "postprocess-failed-source-preserved", + "dimension-restore-fallback", + "unsupported-image-style", + "multiple-generation-warnings" + ]) + ); assert!( parsed["paths"] .get("/api/external/v1/editor/ui-designs/assets/extractions") @@ -944,6 +970,7 @@ mod tests { .get("priceMudPoints") .is_none() ); + assert!(ui_extraction_schema["properties"].get("style").is_none()); assert!( parsed["paths"] .get("/api/external/v1/editor/videos/generations") diff --git a/server-rs/crates/platform-image/licenses/LICENSE.spritefusion-pixel-snapper b/server-rs/crates/platform-image/licenses/LICENSE.spritefusion-pixel-snapper new file mode 100644 index 000000000..6e261805c --- /dev/null +++ b/server-rs/crates/platform-image/licenses/LICENSE.spritefusion-pixel-snapper @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Hugo Duprez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index ccef9f192..41289d985 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -1,7 +1,12 @@ pub mod generated_asset_sheets; pub mod generated_assets; +pub mod pixel_art_snapper; 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, +}; pub use vector_engine::{ DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL, PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, diff --git a/server-rs/crates/platform-image/src/pixel_art_snapper.rs b/server-rs/crates/platform-image/src/pixel_art_snapper.rs new file mode 100644 index 000000000..0ff50cd64 --- /dev/null +++ b/server-rs/crates/platform-image/src/pixel_art_snapper.rs @@ -0,0 +1,1160 @@ +//! Deterministic, in-memory pixel-art grid snapping. +//! +//! The grid detection is adapted from SpriteFusion Pixel Snapper: +//! +//! Copyright (c) 2025 Hugo Duprez, licensed under MIT. The retained license is +//! stored at `licenses/LICENSE.spritefusion-pixel-snapper`. +//! +//! 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 resized back to the RGBA source dimensions with nearest +//! neighbour sampling. + +use std::{error::Error, fmt, io::Cursor, time::Instant}; + +use image::{ + DynamicImage, ImageFormat, Rgba, RgbaImage, + imageops::{self, FilterType}, +}; + +use crate::DownloadedImage; + +pub const PIXEL_ART_ANALYSIS_COLORS: usize = 16; +pub const PIXEL_ART_ALPHA_COVERAGE_THRESHOLD: f64 = 0.375; +pub const PIXEL_ART_KMEANS_SAMPLE_LIMIT: usize = 262_144; +pub const PIXEL_ART_MAX_IMAGE_PIXELS: u64 = 8_294_400; + +const MAX_IMAGE_DIMENSION: u32 = 10_000; +const MAX_DECODE_ALLOC_BYTES: u64 = PIXEL_ART_MAX_IMAGE_PIXELS * 16; +const KMEANS_SEED: u64 = 42; +const MAX_KMEANS_ITERATIONS: usize = 15; +const PEAK_THRESHOLD_MULTIPLIER: f64 = 0.2; +const PEAK_DISTANCE_FILTER: usize = 4; +const WALKER_SEARCH_WINDOW_RATIO: f64 = 0.35; +const WALKER_MIN_SEARCH_WINDOW: f64 = 2.0; +const WALKER_STRENGTH_THRESHOLD: f64 = 0.5; +const MIN_CUTS_PER_AXIS: usize = 4; +const FALLBACK_TARGET_SEGMENTS: usize = 64; +const MAX_STEP_RATIO: f64 = 1.8; + +// 0.375 expressed as an exact rational avoids a floating-point boundary +// ambiguity when coverage lands exactly on the configured threshold. +const ALPHA_THRESHOLD_NUMERATOR: u64 = 3; +const ALPHA_THRESHOLD_DENOMINATOR: u64 = 8; +const DEADLINE_CHECK_INTERVAL: usize = 4_096; + +#[derive(Debug)] +pub enum PixelArtSnapError { + InvalidInput(String), + Decode { + input: &'static str, + message: String, + }, + Encode(String), + Processing(String), + DeadlineExceeded { + stage: &'static str, + }, +} + +impl fmt::Display for PixelArtSnapError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(message) => write!(formatter, "像素规整输入无效:{message}"), + Self::Decode { input, message } => { + write!(formatter, "像素规整无法解码 {input}:{message}") + } + Self::Encode(message) => write!(formatter, "像素规整无法编码 PNG:{message}"), + Self::Processing(message) => write!(formatter, "像素规整处理失败:{message}"), + Self::DeadlineExceeded { stage } => { + write!(formatter, "像素规整超过处理预算,停止于 {stage}") + } + } + } +} + +impl Error for PixelArtSnapError {} + +#[derive(Clone, Copy)] +struct DeadlineGuard { + deadline: Option, +} + +impl DeadlineGuard { + const fn new(deadline: Option) -> Self { + Self { deadline } + } + + fn check(self, stage: &'static str) -> Result<(), PixelArtSnapError> { + if self + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + return Err(PixelArtSnapError::DeadlineExceeded { stage }); + } + Ok(()) + } + + fn check_iteration( + self, + iteration: usize, + stage: &'static str, + ) -> Result<(), PixelArtSnapError> { + if iteration.is_multiple_of(DEADLINE_CHECK_INTERVAL) { + self.check(stage)?; + } + Ok(()) + } +} + +#[derive(Clone, Copy)] +struct SnapConfig { + analysis_colors: usize, + alpha_threshold: f64, + kmeans_sample_limit: usize, + kmeans_seed: u64, + max_kmeans_iterations: usize, + peak_threshold_multiplier: f64, + peak_distance_filter: usize, + walker_search_window_ratio: f64, + walker_min_search_window: f64, + walker_strength_threshold: f64, + min_cuts_per_axis: usize, + fallback_target_segments: usize, + max_step_ratio: f64, +} + +impl SnapConfig { + const PRODUCTION: Self = Self { + analysis_colors: PIXEL_ART_ANALYSIS_COLORS, + alpha_threshold: PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, + kmeans_sample_limit: PIXEL_ART_KMEANS_SAMPLE_LIMIT, + kmeans_seed: KMEANS_SEED, + max_kmeans_iterations: MAX_KMEANS_ITERATIONS, + peak_threshold_multiplier: PEAK_THRESHOLD_MULTIPLIER, + peak_distance_filter: PEAK_DISTANCE_FILTER, + walker_search_window_ratio: WALKER_SEARCH_WINDOW_RATIO, + walker_min_search_window: WALKER_MIN_SEARCH_WINDOW, + walker_strength_threshold: WALKER_STRENGTH_THRESHOLD, + min_cuts_per_axis: MIN_CUTS_PER_AXIS, + fallback_target_segments: FALLBACK_TARGET_SEGMENTS, + max_step_ratio: MAX_STEP_RATIO, + }; +} + +/// Snap an image to its detected logical-pixel grid entirely in memory. +/// +/// `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 +/// 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 { + 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. +pub fn snap_pixel_art_with_deadline( + grid_source: &DownloadedImage, + rgba_source: &DownloadedImage, + deadline: Option, +) -> Result { + let deadline = DeadlineGuard::new(deadline); + deadline.check("输入解码")?; + let rgba_image = decode_rgba_source(rgba_source, "rgba_source", deadline)?; + let decoded_grid_source = if std::ptr::eq(grid_source as *const _, rgba_source as *const _) { + None + } else { + Some(decode_rgba_source(grid_source, "grid_source", deadline)?) + }; + let grid_image = decoded_grid_source.as_ref().unwrap_or(&rgba_image); + + if grid_image.dimensions() != rgba_image.dimensions() { + return Err(PixelArtSnapError::InvalidInput(format!( + "双输入尺寸不一致:grid_source 为 {}×{},rgba_source 为 {}×{}", + grid_image.width(), + grid_image.height(), + rgba_image.width(), + rgba_image.height() + ))); + } + + let config = SnapConfig::PRODUCTION; + let quantized_grid = quantize_for_analysis(grid_image, config, 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); + let (step_x, step_y) = resolve_step_sizes( + estimated_x, + estimated_y, + rgba_image.width(), + rgba_image.height(), + config, + ); + let raw_columns = walk(&profile_x, step_x, rgba_image.width() as usize, config)?; + let raw_rows = walk(&profile_y, step_y, rgba_image.height() as usize, config)?; + let (columns, rows) = stabilize_both_axes( + &profile_x, + &profile_y, + raw_columns, + raw_rows, + rgba_image.width() as usize, + rgba_image.height() as usize, + config, + ); + let logical = resample_soft_alpha_with_deadline( + &rgba_image, + &columns, + &rows, + config.alpha_threshold, + deadline, + )?; + deadline.check("最近邻尺寸恢复")?; + let delivered = resize_nearest(&logical, rgba_image.width(), rgba_image.height()); + deadline.check("PNG 编码")?; + let encoded = encode_png(delivered)?; + deadline.check("PNG 编码")?; + Ok(encoded) +} + +fn decode_rgba_source( + source: &DownloadedImage, + input: &'static str, + deadline: DeadlineGuard, +) -> Result { + deadline.check("输入解码")?; + if source.bytes.is_empty() { + return Err(PixelArtSnapError::InvalidInput(format!("{input} 为空"))); + } + + let dimension_reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice())) + .with_guessed_format() + .map_err(|error| PixelArtSnapError::Decode { + input, + message: format!("识别图片格式失败:{error}"), + })?; + let (width, height) = + dimension_reader + .into_dimensions() + .map_err(|error| PixelArtSnapError::Decode { + input, + message: format!("读取图片尺寸失败:{error}"), + })?; + validate_dimensions(width, height, input)?; + + let mut reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice())) + .with_guessed_format() + .map_err(|error| PixelArtSnapError::Decode { + input, + message: format!("识别图片格式失败:{error}"), + })?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_IMAGE_DIMENSION); + limits.max_image_height = Some(MAX_IMAGE_DIMENSION); + limits.max_alloc = Some(MAX_DECODE_ALLOC_BYTES); + reader.limits(limits); + + let decoded = reader + .decode() + .map(DynamicImage::into_rgba8) + .map_err(|error| PixelArtSnapError::Decode { + input, + message: error.to_string(), + })?; + deadline.check("输入解码")?; + Ok(decoded) +} + +fn validate_dimensions( + width: u32, + height: u32, + input: &'static str, +) -> Result<(), PixelArtSnapError> { + if width < 3 || height < 3 { + return Err(PixelArtSnapError::InvalidInput(format!( + "{input} 尺寸为 {width}×{height},最小尺寸为 3×3" + ))); + } + if width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION { + return Err(PixelArtSnapError::InvalidInput(format!( + "{input} 尺寸为 {width}×{height},单边上限为 {MAX_IMAGE_DIMENSION}" + ))); + } + + let pixel_count = u64::from(width) * u64::from(height); + if pixel_count > PIXEL_ART_MAX_IMAGE_PIXELS { + return Err(PixelArtSnapError::InvalidInput(format!( + "{input} 包含 {pixel_count} 像素,超过 {PIXEL_ART_MAX_IMAGE_PIXELS} 像素上限" + ))); + } + Ok(()) +} + +fn encode_png(image: RgbaImage) -> Result { + let mut cursor = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|error| PixelArtSnapError::Encode(error.to_string()))?; + Ok(DownloadedImage { + bytes: cursor.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + +#[derive(Clone, Copy)] +struct DeterministicRng { + state: u64, +} + +impl DeterministicRng { + fn new(seed: u64) -> Self { + Self { state: seed } + } + + fn next_u64(&mut self) -> u64 { + // SplitMix64 is small, deterministic, and sufficient for K-means + // initialization and bounded reservoir sampling. + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut value = self.state; + value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + value ^ (value >> 31) + } + + fn index(&mut self, upper: usize) -> usize { + debug_assert!(upper > 0); + ((u128::from(self.next_u64()) * upper as u128) >> 64) as usize + } + + fn unit_f64(&mut self) -> f64 { + const SCALE: f64 = 1.0 / ((1u64 << 53) as f64); + ((self.next_u64() >> 11) as f64) * SCALE + } +} + +fn quantize_for_analysis( + source: &RgbaImage, + config: SnapConfig, + deadline: DeadlineGuard, +) -> Result { + deadline.check("颜色分析采样")?; + if config.analysis_colors == 0 { + return Err(PixelArtSnapError::Processing( + "分析颜色数必须大于 0".to_string(), + )); + } + + let mut rng = DeterministicRng::new(config.kmeans_seed); + let mut training_pixels = Vec::<[f32; 3]>::new(); + let mut visible_count = 0usize; + for (pixel_index, pixel) in source.pixels().enumerate() { + deadline.check_iteration(pixel_index, "颜色分析采样")?; + if pixel[3] == 0 { + continue; + } + visible_count = visible_count.saturating_add(1); + let rgb = [pixel[0] as f32, pixel[1] as f32, pixel[2] as f32]; + if training_pixels.len() < config.kmeans_sample_limit { + training_pixels.push(rgb); + continue; + } + + let replacement = rng.index(visible_count); + if replacement < config.kmeans_sample_limit { + training_pixels[replacement] = rgb; + } + } + + if visible_count == 0 { + return Ok(source.clone()); + } + if config.kmeans_sample_limit == 0 { + return Err(PixelArtSnapError::Processing( + "K-means 采样上限必须大于 0".to_string(), + )); + } + + let centroid_count = config.analysis_colors.min(training_pixels.len()); + let mut centroids = + initialize_kmeans_plus_plus(&training_pixels, centroid_count, &mut rng, deadline)?; + let mut previous_centroids = centroids.clone(); + + for iteration in 0..config.max_kmeans_iterations { + deadline.check("K-means 聚类")?; + let mut sums = vec![[0.0f64; 3]; centroid_count]; + let mut counts = vec![0usize; centroid_count]; + for (pixel_index, pixel) in training_pixels.iter().enumerate() { + deadline.check_iteration(pixel_index, "K-means 聚类")?; + let centroid_index = nearest_centroid_index(*pixel, ¢roids); + sums[centroid_index][0] += f64::from(pixel[0]); + sums[centroid_index][1] += f64::from(pixel[1]); + sums[centroid_index][2] += f64::from(pixel[2]); + counts[centroid_index] = counts[centroid_index].saturating_add(1); + } + + let mut next_centroids = centroids.clone(); + for index in 0..centroid_count { + if counts[index] == 0 { + continue; + } + let count = counts[index] as f64; + next_centroids[index] = [ + (sums[index][0] / count) as f32, + (sums[index][1] / count) as f32, + (sums[index][2] / count) as f32, + ]; + } + centroids = next_centroids; + + if iteration > 0 + && centroids + .iter() + .zip(&previous_centroids) + .map(|(current, previous)| distance_squared(*current, *previous)) + .fold(0.0f32, f32::max) + < 0.01 + { + break; + } + previous_centroids.clone_from(¢roids); + } + + let mut quantized = source.clone(); + for (pixel_index, pixel) in quantized.pixels_mut().enumerate() { + deadline.check_iteration(pixel_index, "颜色分析映射")?; + if pixel[3] == 0 { + continue; + } + let source_rgb = [pixel[0] as f32, pixel[1] as f32, pixel[2] as f32]; + let centroid = centroids[nearest_centroid_index(source_rgb, ¢roids)]; + pixel[0] = round_channel(centroid[0]); + pixel[1] = round_channel(centroid[1]); + pixel[2] = round_channel(centroid[2]); + } + Ok(quantized) +} + +fn initialize_kmeans_plus_plus( + pixels: &[[f32; 3]], + centroid_count: usize, + rng: &mut DeterministicRng, + deadline: DeadlineGuard, +) -> Result, PixelArtSnapError> { + debug_assert!(!pixels.is_empty()); + debug_assert!(centroid_count > 0); + + let mut centroids = Vec::with_capacity(centroid_count); + centroids.push(pixels[rng.index(pixels.len())]); + let mut closest_distances = vec![f32::MAX; pixels.len()]; + + for _ in 1..centroid_count { + deadline.check("K-means 初始化")?; + let newest = *centroids.last().expect("at least one centroid"); + let mut total = 0.0f64; + for (index, pixel) in pixels.iter().enumerate() { + deadline.check_iteration(index, "K-means 初始化")?; + let distance = distance_squared(*pixel, newest); + if distance < closest_distances[index] { + closest_distances[index] = distance; + } + total += f64::from(closest_distances[index]); + } + + if total <= 0.0 { + centroids.push(pixels[rng.index(pixels.len())]); + continue; + } + + let target = rng.unit_f64() * total; + let mut cumulative = 0.0f64; + let mut selected = pixels.len() - 1; + for (index, distance) in closest_distances.iter().enumerate() { + deadline.check_iteration(index, "K-means 初始化")?; + cumulative += f64::from(*distance); + if cumulative > target { + selected = index; + break; + } + } + centroids.push(pixels[selected]); + } + Ok(centroids) +} + +fn nearest_centroid_index(pixel: [f32; 3], centroids: &[[f32; 3]]) -> usize { + let mut best_index = 0usize; + let mut best_distance = f32::MAX; + for (index, centroid) in centroids.iter().enumerate() { + let distance = distance_squared(pixel, *centroid); + if distance < best_distance { + best_distance = distance; + best_index = index; + } + } + best_index +} + +fn distance_squared(left: [f32; 3], right: [f32; 3]) -> f32 { + let red = left[0] - right[0]; + let green = left[1] - right[1]; + let blue = left[2] - right[2]; + red * red + green * green + blue * blue +} + +fn round_channel(value: f32) -> u8 { + (value + 0.5).floor().clamp(0.0, 255.0) as u8 +} + +fn compute_profiles( + source: &RgbaImage, + deadline: DeadlineGuard, +) -> Result<(Vec, Vec), PixelArtSnapError> { + deadline.check("网格边缘分析")?; + let (width, height) = source.dimensions(); + if width < 3 || height < 3 { + return Err(PixelArtSnapError::Processing( + "网格分析要求图片至少为 3×3".to_string(), + )); + } + + let width = width as usize; + let height = height as usize; + let pixels = source.as_raw(); + let mut profile_x = vec![0.0f64; width]; + let mut profile_y = vec![0.0f64; height]; + + for y in 0..height { + deadline.check("网格横向边缘分析")?; + for (x, value) in profile_x.iter_mut().enumerate().take(width - 1).skip(1) { + let left = rgba_luminance(pixels, y * width + x - 1); + let right = rgba_luminance(pixels, y * width + x + 1); + *value += (right - left).abs(); + } + } + for (y, value) in profile_y.iter_mut().enumerate().take(height - 1).skip(1) { + deadline.check("网格纵向边缘分析")?; + for x in 0..width { + let top = rgba_luminance(pixels, (y - 1) * width + x); + let bottom = rgba_luminance(pixels, (y + 1) * width + x); + *value += (bottom - top).abs(); + } + } + Ok((profile_x, profile_y)) +} + +fn rgba_luminance(pixels: &[u8], pixel_index: usize) -> f64 { + let offset = pixel_index * 4; + if pixels[offset + 3] == 0 { + 0.0 + } else { + pixels[offset] as f64 * 0.299 + + pixels[offset + 1] as f64 * 0.587 + + pixels[offset + 2] as f64 * 0.114 + } +} + +fn estimate_step_size(profile: &[f64], config: SnapConfig) -> Option { + let maximum = profile.iter().copied().fold(0.0f64, f64::max); + if maximum <= 0.0 { + return None; + } + let threshold = maximum * config.peak_threshold_multiplier; + let peaks = (1..profile.len().saturating_sub(1)) + .filter(|&index| { + profile[index] > threshold + && profile[index] > profile[index - 1] + && profile[index] > profile[index + 1] + }) + .collect::>(); + if peaks.len() < 2 { + return None; + } + + let mut clean_peaks = vec![peaks[0]]; + for peak in peaks.into_iter().skip(1) { + if peak - clean_peaks[clean_peaks.len() - 1] > config.peak_distance_filter - 1 { + clean_peaks.push(peak); + } + } + if clean_peaks.len() < 2 { + return None; + } + + let mut differences = clean_peaks + .windows(2) + .map(|pair| (pair[1] - pair[0]) as f64) + .collect::>(); + differences.sort_by(f64::total_cmp); + Some(differences[differences.len() / 2]) +} + +fn resolve_step_sizes( + estimated_x: Option, + estimated_y: Option, + width: u32, + height: u32, + config: SnapConfig, +) -> (f64, f64) { + match (estimated_x, estimated_y) { + (Some(step_x), Some(step_y)) => { + let ratio = step_x.max(step_y) / step_x.min(step_y); + if ratio > config.max_step_ratio { + let smaller = step_x.min(step_y); + (smaller, smaller) + } else { + let average = (step_x + step_y) / 2.0; + (average, average) + } + } + (Some(step), None) | (None, Some(step)) => (step, step), + (None, None) => { + let fallback = + (f64::from(width.min(height)) / config.fallback_target_segments as f64).max(1.0); + (fallback, fallback) + } + } +} + +fn walk( + profile: &[f64], + step_size: f64, + limit: usize, + config: SnapConfig, +) -> Result, PixelArtSnapError> { + if profile.is_empty() { + return Err(PixelArtSnapError::Processing( + "无法在空边缘 profile 上检测网格".to_string(), + )); + } + if !step_size.is_finite() || step_size < 1.0 { + return Err(PixelArtSnapError::Processing( + "检测到无效像素网格步长".to_string(), + )); + } + + let mut cuts = vec![0usize]; + let mut current_position = 0.0f64; + let search_window = + (step_size * config.walker_search_window_ratio).max(config.walker_min_search_window); + let mean = profile.iter().sum::() / profile.len() as f64; + + while current_position < limit as f64 { + let target = current_position + step_size; + if target >= limit as f64 { + cuts.push(limit); + break; + } + + let start = (target - search_window) + .max(current_position + 1.0) + .max(0.0) as usize; + let end = ((target + search_window) as usize).min(limit); + if end <= start { + current_position = target; + continue; + } + + let mut best_index = start; + let mut best_value = -1.0f64; + for (index, value) in profile.iter().enumerate().take(end).skip(start) { + if *value > best_value { + best_value = *value; + best_index = index; + } + } + + if best_value > mean * config.walker_strength_threshold { + cuts.push(best_index); + current_position = best_index as f64; + } else { + cuts.push(target as usize); + current_position = target; + } + } + Ok(cuts) +} + +fn stabilize_both_axes( + profile_x: &[f64], + profile_y: &[f64], + raw_columns: Vec, + raw_rows: Vec, + width: usize, + height: usize, + config: SnapConfig, +) -> (Vec, Vec) { + let mut columns = stabilize_cuts( + profile_x, + raw_columns.clone(), + width, + &raw_rows, + height, + config, + ); + let mut rows = stabilize_cuts(profile_y, raw_rows, height, &raw_columns, width, config); + + let column_cells = columns.len().saturating_sub(1).max(1); + let row_cells = rows.len().saturating_sub(1).max(1); + let column_step = width as f64 / column_cells as f64; + let row_step = height as f64 / row_cells as f64; + let step_ratio = column_step.max(row_step) / column_step.min(row_step); + if step_ratio <= config.max_step_ratio { + return (columns, rows); + } + + let target_step = column_step.min(row_step); + if column_step > target_step * 1.2 { + columns = snap_uniform_cuts( + profile_x, + width, + target_step, + config, + config.min_cuts_per_axis, + ); + } + if row_step > target_step * 1.2 { + rows = snap_uniform_cuts( + profile_y, + height, + target_step, + config, + config.min_cuts_per_axis, + ); + } + (columns, rows) +} + +fn stabilize_cuts( + profile: &[f64], + cuts: Vec, + limit: usize, + sibling_cuts: &[usize], + sibling_limit: usize, + config: SnapConfig, +) -> Vec { + if limit == 0 { + return vec![0]; + } + + let cuts = sanitize_cuts(cuts, limit); + let min_required = config.min_cuts_per_axis.max(2).min(limit + 1); + let axis_cells = cuts.len().saturating_sub(1); + let sibling_cells = sibling_cuts.len().saturating_sub(1); + let sibling_has_grid = + sibling_limit > 0 && sibling_cells >= min_required.saturating_sub(1) && sibling_cells > 0; + let steps_skewed = sibling_has_grid && axis_cells > 0 && { + let axis_step = limit as f64 / axis_cells as f64; + let sibling_step = sibling_limit as f64 / sibling_cells as f64; + let ratio = axis_step / sibling_step; + ratio > config.max_step_ratio || ratio < 1.0 / config.max_step_ratio + }; + + if cuts.len() >= min_required && !steps_skewed { + return cuts; + } + + let mut target_step = if sibling_has_grid { + sibling_limit as f64 / sibling_cells as f64 + } else if config.fallback_target_segments > 1 { + limit as f64 / config.fallback_target_segments as f64 + } else if axis_cells > 0 { + limit as f64 / axis_cells as f64 + } else { + limit as f64 + }; + if !target_step.is_finite() || target_step <= 0.0 { + target_step = 1.0; + } + snap_uniform_cuts(profile, limit, target_step, config, min_required) +} + +fn sanitize_cuts(mut cuts: Vec, limit: usize) -> Vec { + if limit == 0 { + return vec![0]; + } + for cut in &mut cuts { + *cut = (*cut).min(limit); + } + cuts.push(0); + cuts.push(limit); + cuts.sort_unstable(); + cuts.dedup(); + cuts +} + +fn snap_uniform_cuts( + profile: &[f64], + limit: usize, + target_step: f64, + config: SnapConfig, + min_required: usize, +) -> Vec { + if limit == 0 { + return vec![0]; + } + if limit == 1 { + return vec![0, 1]; + } + + let desired_cells = if target_step.is_finite() && target_step > 0.0 { + round_positive(limit as f64 / target_step) + } else { + 0 + } + .max(min_required.saturating_sub(1)) + .max(1) + .min(limit); + let cell_width = limit as f64 / desired_cells as f64; + let search_window = + (cell_width * config.walker_search_window_ratio).max(config.walker_min_search_window); + let mean = if profile.is_empty() { + 0.0 + } else { + profile.iter().sum::() / profile.len() as f64 + }; + + let mut cuts = Vec::with_capacity(desired_cells + 1); + cuts.push(0); + for cell_index in 1..desired_cells { + let target = cell_width * cell_index as f64; + let previous = cuts[cuts.len() - 1]; + if previous + 1 >= limit { + break; + } + + let mut start = (target - search_window).floor() as isize; + start = start.max(previous as isize + 1).max(0); + let mut end = (target + search_window).ceil() as isize; + end = end.min(limit as isize - 1); + if end < start { + start = previous as isize + 1; + end = start; + } + + let start = start as usize; + let end = end as usize; + let mut best_index = start.min(profile.len().saturating_sub(1)); + let mut best_value = -1.0f64; + for (index, value) in profile + .iter() + .enumerate() + .take(end.min(profile.len().saturating_sub(1)) + 1) + .skip(start) + { + if *value > best_value { + best_value = *value; + best_index = index; + } + } + + if best_value < mean * config.walker_strength_threshold { + best_index = round_positive(target) + .max(previous + 1) + .min((limit - 1).max(previous + 1)); + } + cuts.push(best_index); + } + cuts.push(limit); + sanitize_cuts(cuts, limit) +} + +fn round_positive(value: f64) -> usize { + (value + 0.5).floor() as usize +} + +#[cfg(test)] +fn resample_soft_alpha( + source: &RgbaImage, + columns: &[usize], + rows: &[usize], + alpha_threshold: f64, +) -> Result { + resample_soft_alpha_with_deadline( + source, + columns, + rows, + alpha_threshold, + DeadlineGuard::new(None), + ) +} + +fn resample_soft_alpha_with_deadline( + source: &RgbaImage, + columns: &[usize], + rows: &[usize], + alpha_threshold: f64, + deadline: DeadlineGuard, +) -> Result { + deadline.check("RGBA 逻辑像素采样")?; + if columns.len() < 2 || rows.len() < 2 { + return Err(PixelArtSnapError::Processing( + "网格切线不足,无法采样".to_string(), + )); + } + if (alpha_threshold - PIXEL_ART_ALPHA_COVERAGE_THRESHOLD).abs() > f64::EPSILON { + return Err(PixelArtSnapError::Processing( + "生产像素规整仅支持固定 Alpha 覆盖率阈值".to_string(), + )); + } + + let width = source.width() as usize; + let height = source.height() as usize; + let pixels = source.as_raw(); + let mut output = RgbaImage::new((columns.len() - 1) as u32, (rows.len() - 1) as u32); + + for (output_y, row) in rows.windows(2).enumerate() { + deadline.check("RGBA 逻辑像素采样")?; + let start_y = row[0].min(height); + let end_y = row[1].min(height); + for (output_x, column) in columns.windows(2).enumerate() { + let start_x = column[0].min(width); + let end_x = column[1].min(width); + if end_x <= start_x || end_y <= start_y { + continue; + } + + let mut alpha_sum = 0u64; + let mut weighted_red = 0u64; + let mut weighted_green = 0u64; + let mut weighted_blue = 0u64; + for y in start_y..end_y { + for x in start_x..end_x { + let pixel_index = y * width + x; + deadline.check_iteration(pixel_index, "RGBA 逻辑像素采样")?; + let offset = pixel_index * 4; + let alpha = u64::from(pixels[offset + 3]); + alpha_sum += alpha; + weighted_red += u64::from(pixels[offset]) * alpha; + weighted_green += u64::from(pixels[offset + 1]) * alpha; + weighted_blue += u64::from(pixels[offset + 2]) * alpha; + } + } + + let cell_pixels = ((end_x - start_x) * (end_y - start_y)) as u64; + if alpha_sum == 0 || !coverage_meets_threshold(alpha_sum, cell_pixels) { + continue; + } + + output.put_pixel( + output_x as u32, + output_y as u32, + Rgba([ + rounded_weighted_channel(weighted_red, alpha_sum), + rounded_weighted_channel(weighted_green, alpha_sum), + rounded_weighted_channel(weighted_blue, alpha_sum), + 255, + ]), + ); + } + } + Ok(output) +} + +fn coverage_meets_threshold(alpha_sum: u64, pixel_count: u64) -> bool { + alpha_sum * ALPHA_THRESHOLD_DENOMINATOR >= pixel_count * 255 * ALPHA_THRESHOLD_NUMERATOR +} + +fn rounded_weighted_channel(weighted_sum: u64, alpha_sum: u64) -> u8 { + ((weighted_sum + alpha_sum / 2) / alpha_sum).min(255) as u8 +} + +fn resize_nearest(source: &RgbaImage, width: u32, height: u32) -> RgbaImage { + imageops::resize(source, width, height, FilterType::Nearest) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn downloaded_png(image: RgbaImage) -> DownloadedImage { + encode_png(image).expect("test PNG should encode") + } + + fn decode_output(image: &DownloadedImage) -> RgbaImage { + image::load_from_memory(&image.bytes) + .expect("output PNG should decode") + .into_rgba8() + } + + #[test] + fn threshold_is_inclusive_at_exact_three_eighths_coverage() { + let mut source = RgbaImage::new(8, 1); + for x in 0..3 { + source.put_pixel(x, 0, Rgba([200, 100, 50, 255])); + } + + let kept = resample_soft_alpha( + &source, + &[0, 8], + &[0, 1], + PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, + ) + .expect("exact threshold should sample"); + assert_eq!(kept.get_pixel(0, 0).0, [200, 100, 50, 255]); + + source.put_pixel(2, 0, Rgba([200, 100, 50, 254])); + let removed = resample_soft_alpha( + &source, + &[0, 8], + &[0, 1], + PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, + ) + .expect("below-threshold cell should sample"); + assert_eq!(removed.get_pixel(0, 0).0, [0, 0, 0, 0]); + } + + #[test] + fn soft_alpha_controls_coverage_and_weights_straight_rgb() { + let source = RgbaImage::from_raw( + 2, + 1, + vec![ + 255, 0, 0, 64, // + 0, 0, 255, 192, + ], + ) + .expect("valid RGBA buffer"); + + let output = resample_soft_alpha( + &source, + &[0, 2], + &[0, 1], + PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, + ) + .expect("soft-alpha cell should sample"); + + assert_eq!(output.get_pixel(0, 0).0, [64, 0, 191, 255]); + } + + #[test] + fn transparent_cells_are_canonical_transparent_black() { + let source = RgbaImage::from_raw( + 2, + 2, + vec![ + 255, 0, 255, 0, 12, 34, 56, 0, // + 90, 80, 70, 0, 1, 2, 3, 0, + ], + ) + .expect("valid RGBA buffer"); + + let output = resample_soft_alpha( + &source, + &[0, 2], + &[0, 2], + PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, + ) + .expect("transparent cell should sample"); + + assert_eq!(output.get_pixel(0, 0).0, [0, 0, 0, 0]); + } + + #[test] + fn dual_inputs_require_identical_dimensions() { + 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"); + assert!( + error + .to_string() + .contains("双输入尺寸不一致:grid_source 为 8×8,rgba_source 为 8×9") + ); + } + + #[test] + fn deadline_aware_api_stops_before_processing_when_budget_is_exhausted() { + let source = downloaded_png(RgbaImage::from_pixel(8, 8, Rgba([10, 20, 30, 255]))); + let expired = Instant::now() + .checked_sub(std::time::Duration::from_millis(1)) + .expect("test deadline should be representable"); + + let error = snap_pixel_art_with_deadline(&source, &source, Some(expired)) + .expect_err("expired deadline should stop pixel snapping"); + + assert!(matches!( + error, + PixelArtSnapError::DeadlineExceeded { + stage: "输入解码" + } + )); + } + + #[test] + fn output_keeps_physical_size_and_uses_nearest_blocks() { + let grid_image = RgbaImage::from_pixel(128, 128, Rgba([0, 0, 0, 255])); + let mut rgba_image = RgbaImage::new(128, 128); + for y in 0..128 { + for x in 0..128 { + rgba_image.put_pixel( + x, + y, + Rgba([ + (x / 2) as u8, + (y / 2) as u8, + ((x / 2 + y / 2) % 256) as u8, + 255, + ]), + ); + } + } + + let output = snap_pixel_art( + &downloaded_png(grid_image), + &downloaded_png(rgba_image.clone()), + ) + .expect("pixel snapping should succeed"); + let decoded = decode_output(&output); + + assert_eq!(decoded.dimensions(), rgba_image.dimensions()); + assert_eq!(decoded, rgba_image); + assert_eq!(output.mime_type, "image/png"); + assert_eq!(output.extension, "png"); + } + + #[test] + fn output_is_deterministic_and_alpha_is_binary() { + let grid = downloaded_png(RgbaImage::from_pixel(128, 128, Rgba([30, 40, 50, 255]))); + let mut rgba = RgbaImage::new(128, 128); + for y in 0..128 { + for x in 0..128 { + let alpha = match (x / 2 + y / 2) % 3 { + 0 => 0, + 1 => 96, + _ => 255, + }; + rgba.put_pixel(x, y, Rgba([x as u8, y as u8, 180, alpha])); + } + } + 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"); + assert_eq!(first.bytes, second.bytes); + + for pixel in decode_output(&first).pixels() { + assert!(pixel[3] == 0 || pixel[3] == 255); + if pixel[3] == 0 { + assert_eq!(pixel.0, [0, 0, 0, 0]); + } + } + } + + #[test] + fn pixel_count_limit_is_enforced_before_decode() { + let error = validate_dimensions(10_000, 1_000, "grid_source") + .expect_err("ten million pixels should exceed the production limit"); + assert!(error.to_string().contains("超过 8294400 像素上限")); + } +} diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 8bf6dafd2..b34449d44 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -836,6 +836,10 @@ describe('ImageCanvasEditorView generation integration', () => { name: '生成图片模型 nanobanana2', }).className, ).toContain('platform-inline-option-button'); + const pixelArtToggle = within(generateDialog).getByRole('checkbox', { + name: '像素艺术', + }) as HTMLInputElement; + expect(pixelArtToggle.checked).toBe(false); expect( within(generateDialog).getByRole('button', { name: '生成' }).className, ).toContain('platform-button'); @@ -847,6 +851,7 @@ describe('ImageCanvasEditorView generation integration', () => { fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张明亮的拼图主视觉' }, }); + fireEvent.click(pixelArtToggle); fireEvent.click( within(generateDialog).getByRole('button', { name: '生成' }), ); @@ -857,6 +862,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect.objectContaining({ prompt: '一张明亮的拼图主视觉', model: 'gemini-3.1-flash-image-preview', + style: 'pixelArt', aspectRatio: '1:1', imageSize: '1K', projectId: 'editor-project-default', @@ -865,6 +871,11 @@ describe('ImageCanvasEditorView generation integration', () => { }), ); }); + const submittedGenerationInputs = + generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs; + expect(JSON.stringify(submittedGenerationInputs)).not.toContain( + 'pixelArt', + ); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index b04344799..c613b81d7 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -7,6 +7,7 @@ import { createLayerFromAsset, DEFAULT_CANVAS_BACKGROUND_COLOR, formatCanvasDisplayScalePercent, + hydrateCanvasGenerationDialog, hydrateLayer, normalizeAssetLibrary, normalizeCanvasBackgroundHex, @@ -631,6 +632,7 @@ describe('ImageCanvasEditorModel', () => { composerOpen: false, generatedLayerId: 'layer-generated', imageModel: 'gpt-image-2', + style: 'pixelArt', generationStartedAt: 1_771_400_000_000, generationFinishedAt: 1_771_400_004_000, placeholder: { @@ -688,6 +690,7 @@ describe('ImageCanvasEditorModel', () => { status: 'generating', generatedLayerId: 'layer-generated', imageModel: 'gpt-image-2', + style: 'pixelArt', generationStartedAt: 1_771_400_000_000, generationFinishedAt: 1_771_400_004_000, placeholder: { @@ -704,6 +707,35 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('defaults restored supported image styles to none and drops them from other modes', () => { + expect( + hydrateCanvasGenerationDialog({ + id: 'generation-dialog-legacy', + mode: 'generate', + prompt: '旧任务', + status: 'idle', + })?.style, + ).toBe('none'); + expect( + hydrateCanvasGenerationDialog({ + id: 'generation-dialog-unknown-style', + mode: 'character', + prompt: '未知风格', + status: 'idle', + style: 'futureStyle', + })?.style, + ).toBe('none'); + expect( + hydrateCanvasGenerationDialog({ + id: 'generation-dialog-spec', + mode: 'spec', + prompt: '规范任务', + status: 'idle', + style: 'pixelArt', + })?.style, + ).toBeUndefined(); + }); + it('drops restored generator references owned by another user', () => { const dialog: CanvasGenerationDialogState = { id: 'generation-dialog-owner', diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index f2ce226a8..bbc1db6b7 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -537,6 +537,14 @@ export function hydrateCanvasGenerationDialog( if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) { return null; } + const style = + snapshot.mode === 'generate' || + snapshot.mode === 'character' || + snapshot.mode === 'icon' + ? snapshot.style === 'pixelArt' + ? 'pixelArt' + : 'none' + : undefined; return { id, @@ -601,6 +609,7 @@ export function hydrateCanvasGenerationDialog( currentUserId, ), imageModel: stringOrUndefined(snapshot.imageModel), + style, videoModel: typeof snapshot.videoModel === 'string' ? snapshot.videoModel diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index f6d0e73f2..67466e2e8 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -5,6 +5,7 @@ import type { EditorCharacterAnimationGenerationResult, EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, + EditorImageGenerationStyle, EditorVideoAspectRatio, EditorVideoModel, EditorVideoResolution, @@ -215,6 +216,7 @@ export type GenerateDialogState = { publicationReferences?: CharacterReferenceImage[]; uiDesignSpecReference?: CharacterReferenceImage | null; imageModel?: string; + style?: EditorImageGenerationStyle; videoModel?: EditorVideoModel; videoAspectRatio?: EditorVideoAspectRatio; videoResolution?: EditorVideoResolution; diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 6ffcdefcf..fbcf383f7 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -69,6 +69,7 @@ describe('ImageCanvasGenerationDialogModel', () => { mode: 'generate', status: 'idle', composerOpen: true, + style: 'none', placeholder: { x: -312, y: -332, @@ -156,6 +157,7 @@ describe('ImageCanvasGenerationDialogModel', () => { }), ).toMatchObject({ mode: 'character', + style: 'none', imageModel: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', @@ -176,6 +178,7 @@ describe('ImageCanvasGenerationDialogModel', () => { }), ).toMatchObject({ mode: 'icon', + style: 'none', imageModel: 'unknown-model', aspectRatio: '1:1', imageSize: '1K', @@ -609,6 +612,7 @@ describe('ImageCanvasGenerationDialogModel', () => { imageModel: 'gpt-image-2', aspectRatio: '2:3', imageSize: '2K', + style: 'pixelArt', }, mode: 'redraw', }), @@ -619,6 +623,7 @@ describe('ImageCanvasGenerationDialogModel', () => { imageModel: 'gpt-image-2', aspectRatio: '2:3', imageSize: '2K', + style: 'none', placeholder: { x: 472, y: 140, diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index ee9c2dc0a..50ff65755 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -196,6 +196,7 @@ export function createGenerateDialogDraft({ prompt: '', status: 'idle', composerOpen: true, + style: 'none', imageModel: DEFAULT_IMAGE_MODEL, aspectRatio: dimensionDefaults.aspectRatio, imageSize: dimensionDefaults.imageSize, @@ -269,6 +270,7 @@ export function createCharacterGenerationDialogDraft({ prompt: '', status: 'idle', composerOpen: true, + style: 'none', characterSpecReference: null, characterReferences: [], imageModel: normalizedImageModel, @@ -307,6 +309,7 @@ export function createIconGenerationDialogDraft({ prompt: '', status: 'idle', composerOpen: true, + style: 'none', iconSpecReference: null, generationReferences: [], iconDescriptions: [], diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx index 827601f0e..d00774068 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -47,6 +47,7 @@ function ImageOptionsHarness({ {dialog.imageModel} {dialog.aspectRatio} {dialog.imageSize} + {dialog.style ?? '-'} {dialog.status} {dialog.errorMessage ?? '-'} @@ -66,6 +67,67 @@ function ImageOptionsHarness({ } describe('ImageCanvasGenerationImageOptionsView', () => { + it.each(['generate', 'character', 'icon'] as const)( + 'shows and updates the pixel art style for %s generation', + (mode) => { + render( + , + ); + + const toggle = screen.getByRole('checkbox', { + name: '像素艺术', + }) as HTMLInputElement; + const modelButton = screen.getByRole('button', { + name: '生成图片模型 nanobanana2', + }); + expect(toggle.checked).toBe(false); + expect( + toggle.compareDocumentPosition(modelButton) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + + fireEvent.click(toggle); + + expect(toggle.checked).toBe(true); + expect(screen.getByLabelText('当前风格').textContent).toBe('pixelArt'); + expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); + expect(screen.getByLabelText('当前错误').textContent).toBe('-'); + }, + ); + + it.each(['spec', 'quick-edit', 'ui-design', 'publication'] as const)( + 'does not show the pixel art style for %s generation', + (mode) => { + render( + , + ); + + expect( + screen.queryByRole('checkbox', { name: '像素艺术' }), + ).toBeNull(); + }, + ); + it('updates dimensions from a menu, keeps the menu open and marks the selection', () => { render( (null); const modelButtonRef = useRef(null); const isQuickEdit = dialog.mode === 'quick-edit'; + const supportsImageStyle = + dialog.mode === 'generate' || + dialog.mode === 'character' || + dialog.mode === 'icon'; const normalizedLockedModel = lockedModel ? normalizeEditorImageModel(lockedModel) : null; @@ -306,6 +310,21 @@ export function ImageCanvasGenerationImageOptionsView({ : null} ) : null} + {supportsImageStyle ? ( + + ) : null} {includeModel ? (
{ input: { prompt: '一张发光主视觉', model: 'gemini-3.1-flash-image-preview', + style: 'none', aspectRatio: '1:1', imageSize: '1K', }, @@ -193,6 +194,24 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); + it('does not send image style from an incomplete quick-edit snapshot', () => { + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'quick-edit', + prompt: '把当前图改成雨天', + status: 'idle', + style: 'pixelArt', + }, + layers: [], + nextGeneratedIndex: 4, + }); + + expect(plan.kind).toBe('image'); + expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty( + 'style', + ); + }); + it('builds spec generation plans with reference prompt semantics', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { @@ -294,6 +313,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { prompt: ' 白发骑士 ', status: 'idle', imageModel: 'gpt-image-2', + style: 'pixelArt', aspectRatio: '2:3', imageSize: '2K', characterSpecReference: { @@ -324,6 +344,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { model: 'gpt-image-2', screenColor: 'auto', segModel: 'birefnet', + style: 'pixelArt', aspectRatio: '2:3', imageSize: '2K', referenceImageSrcs: [ @@ -432,6 +453,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { mode: 'publication', prompt: '', status: 'idle', + style: 'pixelArt', publicationGameInfo: { gameName: ' 重庆洪崖洞火锅 ', gameCategories: '抓大鹅、休闲、治愈、手绘风', @@ -665,6 +687,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { prompt: ' 返回按钮 \n\n设置按钮', status: 'idle', imageModel: 'gpt-image-2', + style: 'pixelArt', aspectRatio: '3:2', imageSize: '2K', assetLabel: ' 冒险游戏图标 ', @@ -695,6 +718,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { model: 'gpt-image-2', screenColor: 'auto', segModel: 'birefnet', + style: 'pixelArt', aspectRatio: '3:2', imageSize: '2K', }, diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 2dae49c16..443b2a0d4 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -354,6 +354,7 @@ export function buildImageGenerationSubmissionPlan({ model: imageModel, screenColor, segModel, + style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none', aspectRatio: dialog.aspectRatio ?? '1:1', imageSize: dialog.imageSize ?? '1K', ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), @@ -564,6 +565,9 @@ export function buildImageGenerationSubmissionPlan({ input: { prompt: normalizedPrompt, model: imageModel, + ...(dialog.mode === 'generate' + ? { style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none' } + : {}), aspectRatio: dialog.aspectRatio ?? '1:1', imageSize: dialog.imageSize ?? '1K', ...(dialog.generationReferences?.length @@ -639,6 +643,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan( model: rememberImageModel, screenColor, segModel, + style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none', aspectRatio: dialog.aspectRatio ?? '1:1', imageSize: dialog.imageSize ?? '1K', }, diff --git a/src/index.css b/src/index.css index 6c057a37b..fe4cf92e8 100644 --- a/src/index.css +++ b/src/index.css @@ -7079,6 +7079,33 @@ button.image-canvas-editor__reference-chip:disabled { width: 8.5rem; } +.image-canvas-editor__image-style-toggle { + grid-column: 3; + display: inline-flex; + min-height: 2.25rem; + align-items: center; + justify-self: end; + gap: 0.38rem; + padding: 0 0.5rem; + color: #475569; + font-size: 0.78rem; + font-weight: 760; + white-space: nowrap; + cursor: pointer; +} + +.image-canvas-editor__image-style-toggle input { + width: 0.95rem; + height: 0.95rem; + margin: 0; + accent-color: var(--image-canvas-brand-fill); +} + +.image-canvas-editor__image-style-toggle:has(input:disabled) { + color: #94a3b8; + cursor: wait; +} + .image-canvas-editor__model-trigger-label { display: inline-flex; min-width: 0; @@ -8904,6 +8931,7 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__option-cluster--dimensions, .image-canvas-editor__option-cluster--model, + .image-canvas-editor__image-style-toggle, .image-canvas-editor__readonly-generation-option, .image-canvas-editor__generation-submit, .image-canvas-editor__character-animation-submit { @@ -8913,6 +8941,10 @@ button.image-canvas-editor__reference-chip:disabled { justify-self: stretch; } + .image-canvas-editor__image-style-toggle { + justify-content: flex-start; + } + .image-canvas-editor__generation-submit { width: 100%; } diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index a21136fe5..ce9133c08 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -838,6 +838,7 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', screenColor: '#FFD6C2', segModel: 'anime-seg', + style: 'pixelArt', aspectRatio: '2:3', imageSize: '1K', projectId: 'editor-project-1', @@ -862,6 +863,7 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', screenColor: '#FFD6C2', segModel: 'anime-seg', + style: 'pixelArt', aspectRatio: '2:3', imageSize: '1K', projectId: 'editor-project-1', @@ -1015,6 +1017,7 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', + style: 'none', aspectRatio: '1:1', imageSize: '2K', projectId: 'editor-project-1', @@ -1036,6 +1039,7 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', + style: 'none', aspectRatio: '1:1', imageSize: '2K', projectId: 'editor-project-1', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 41a0b0c87..210dc5eda 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -184,6 +184,8 @@ export type EditorAssetLibrarySnapshot = { assets: EditorAssetSnapshot[]; }; +export type EditorImageGenerationStyle = 'none' | 'pixelArt'; + export type EditorImageGenerationInput = { prompt: string; size?: string; @@ -196,6 +198,7 @@ export type EditorImageGenerationInput = { model?: string; screenColor?: string; segModel?: string; + style?: EditorImageGenerationStyle; aspectRatio?: string; imageSize?: string; referenceImageSrcs?: string[]; @@ -228,6 +231,7 @@ export type EditorIconSpritesheetGenerationInput = { model?: string; screenColor?: string; segModel?: string; + style?: EditorImageGenerationStyle; aspectRatio?: string; imageSize?: string; projectId?: string | null; @@ -920,6 +924,7 @@ export async function generateEditorImage(input: EditorImageGenerationInput) { ...(input.model ? { model: input.model } : {}), ...(input.screenColor ? { screenColor: input.screenColor } : {}), ...(input.segModel ? { segModel: input.segModel } : {}), + ...(input.style ? { style: input.style } : {}), ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), ...(input.imageSize ? { imageSize: input.imageSize } : {}), ...(input.referenceImageSrcs?.length @@ -966,6 +971,7 @@ export async function generateEditorIconSpritesheet( model: input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2, ...(input.screenColor ? { screenColor: input.screenColor } : {}), ...(input.segModel ? { segModel: input.segModel } : {}), + ...(input.style ? { style: input.style } : {}), ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), ...(input.imageSize ? { imageSize: input.imageSize } : {}), ...(input.projectId ? { projectId: input.projectId } : {}), From 1ed8064d2fb81196b56ac18dae33648f654246ba Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 04:52:29 +0000 Subject: [PATCH 02/17] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=9B=BE=E9=9B=86=E5=85=A8=E8=BF=9E=E9=80=9A=E5=9F=9F=E6=8B=86?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除前端从提示词解析素材数量的逻辑,完整提示词作为单个请求元素提交。 删除后端按描述数量截断切片的路径,自动拆分与手动拆分复用全连通域算法。 统一切片命名和尺寸、像素、数量限制,保留手动拆分按钮与接口。 更新既有测试、OpenAPI、编辑器文档和共享决策记录。 --- .../genarrative-external-v1.openapi.json | 5 +- .../shared-memory/decision-log.md | 12 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 22 +- .../crates/api-server/src/editor_project.rs | 232 +++++++++--------- .../src/generated_asset_sheets/mod.rs | 1 - .../src/generated_asset_sheets/sheet.rs | 133 ++-------- ...CanvasEditorGenerationIntegration.test.tsx | 2 +- .../ImageCanvasGenerationDialogModel.test.ts | 12 +- .../ImageCanvasGenerationDialogModel.ts | 30 +-- .../ImageCanvasGenerationModel.ts | 1 - ...ageCanvasGenerationSubmissionModel.test.ts | 10 +- .../ImageCanvasGenerationSubmissionModel.ts | 19 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 6 +- .../useImageCanvasGenerationWorkflow.ts | 15 -- 15 files changed, 194 insertions(+), 310 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index d170c457f..5fea17d08 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2965,6 +2965,7 @@ }, "iconDescriptions": { "type": "array", + "description": "图标生成需求文本数组,供 prompt 组装使用;数组长度不控制自动切片数量。画布前端把完整用户提示词作为唯一数组元素提交;其它调用方可继续提交 1 到 100 条非空文本。", "minItems": 1, "maxItems": 100, "items": { @@ -3247,7 +3248,7 @@ }, "iconImageSrcs": { "type": "array", - "description": "按图集 alpha 连通域拆分并持久化的独立素材列表。", + "description": "识别图集中全部有效 alpha 连通域并持久化的独立素材列表,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。自动生成与手动拆分图集使用相同识别规则。", "items": { "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } @@ -3261,7 +3262,7 @@ "type": "null" } ], - "description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。透明背景最终失败时不会进入拆分;风格归一化或像素规整产生通用 warning 时,两者可以并存。" + "description": "图集已成功持久化,但全连通域自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。自动拆分不按 iconDescriptions 数量校验切片数。透明背景最终失败时不会进入拆分;风格归一化或像素规整产生通用 warning 时,两者可以并存。" }, "prompt": { "type": "string" diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 30c7b019a..49892bd75 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -257,7 +257,7 @@ - 背景:图片画布的普通图片、规范、角色、图标图集、UI 设计、宣发素材、视频和音频默认使用“类型 + 数字”命名,用户只能在生成后单独重命名素材,画布图层、项目资源和素材库名称容易不一致。 - 决策:主生成状态继续使用可选 `assetLabel`,名称最多 80 个字符并在提交时去除首尾空格;当前生成面板不展示“资源名称”标签和输入框,默认沿用现有自动编号名称,历史状态或内部调用若携带非空名称,仍必须让同一个名称贯穿 `assetLabel`、`canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。移除名称输入后,角色、图标图集、UI 设计和角色动作等提示词输入恢复统一可见边框。 -- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、角色动作绿幕预览等具有独立复用价值的中间产物基于主名称追加“(原图)”等后缀;普通图片和图片修改的纯尺寸变换在内存完成后只上传一次,不生成“原始输出”副本。图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。 +- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、角色动作绿幕预览等具有独立复用价值的中间产物基于主名称追加“(原图)”等后缀;普通图片和图片修改的纯尺寸变换在内存完成后只上传一次,不生成“原始输出”副本。2026-07-29 起,图标切片不再按用户提示词命名,统一按全连通域视觉顺序命名为 `素材 N`。 - 影响范围:图片画布生成状态与面板、提交模型、图标和角色动作请求契约、项目资源 / 素材库持久化和相关编辑器文档。 - 验证方式:覆盖生成面板不渲染资源名称输入、提示词边框、空白回退、内部自定义名与长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、Rust 契约与 API 定向测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 @@ -604,7 +604,7 @@ ## 2026-06-18 图片画布 UI 设计图提取素材保留图集 - 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。 -- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放 provider 原图和拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库和画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分素材从 provider 原图右侧继续排列。拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 +- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放 provider 原图和拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库和画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分素材从 provider 原图右侧继续排列。拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。2026-07-29 起,图标生成的自动拆分与手动拆分共同识别全图集有效连通域,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,不再以提示词条目数决定切片数量;手动拆分仍保留且不计费。所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 - 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。 - 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。 @@ -4520,3 +4520,11 @@ - 去背边界:不修改 BgFilter `flat` 参数、`cross_check`、fallback、Alpha 回贴和默认关闭 despill 的现有行为。BgFilter 最终失败时不运行像素规整;像素规整失败按 best-effort 非致命降级,保留进入该步骤前的图片并通过既有通用 `warning` 完成任务,不退款。 - 持久化边界:逻辑低分辨率图、像素化前后对比图、预览、诊断和报告一律不持久化;像素模式只替换原本即将上传的最终图片字节。普通图片、角色、图标的 OSS PUT、asset / project resource 和画布 item 数量必须与 `None` 模式完全一致;角色 / 图标最多因复用失败增加一次对已有 provider 对象的 OSS GET,不得增加 PUT、资源类型、画布项、队列类型或 schema 字段。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/openapi/genarrative-external-v1.openapi.json`。 + +## 2026-07-29 图标图集拆分数量只由有效连通域决定 + +- 背景:图标素材生成前端曾把单个提示词按换行、逗号、顿号等分隔符解析成描述数组,后端再用数组长度作为期望切片数。这会把“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”一类自然语言错误地解释为固定数量,并在图集中存在更多有效素材时截断结果。 +- 决策:画布前端不再从提示词解析素材数量,完整提示词作为 `iconDescriptions` 的唯一数组元素提交以兼容现有请求契约;后端仍允许其它调用方提交多条文本,但数组长度只参与 prompt 组装,绝不作为切片数量或切片命名依据。生成后的自动拆分与手动 `拆分图集` 复用同一套全连通域识别、视觉阅读顺序和 `素材 N` 命名,识别多少个有效素材就拆多少个;手动按钮与 `/api/editor/icon-spritesheets/slices` 路由继续保留。 +- 失败与限制:两条图标拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,并在持久化前完成校验。自动拆分仍是 best-effort,失败后保留整张透明图集并返回 `sliceWarning`;手动拆分失败返回接口错误。UI 设计图素材提取继续使用全连通域识别,不受提示词数量影响。 +- 验证方式:调整既有前端提交、Prompt、连通域切片、上限和响应契约测试,不新增仅用于证明旧解析函数已删除的测试;运行前后端定向测试、类型与 Rust 检查、编码检查和 `git diff --check`。 +- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/openapi/genarrative-external-v1.openapi.json`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 95a84554e..7e9dde17b 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -75,7 +75,7 @@ - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 -- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。 +- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 @@ -106,7 +106,7 @@ - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程先按 provider 原始分辨率保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连);透明处理成功时只重采样透明图的 alpha 蒙版并应用回 provider 原图 RGB,最后把透明主结果归一到统一业务像素;最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 -- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并尝试拆分。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 +- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和完整用户需求生成 spritesheet;为兼容现有契约,画布前端把完整文本作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 176d23f6b..201910da5 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -2,11 +2,11 @@ 日期:`2026-06-15` -更新时间:`2026-07-28` +更新时间:`2026-07-29` ## 背景 -图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet;后端去背景正常成功后,再尝试自动拆分为可独立编辑的素材。 +图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于通过一段完整需求生成一张纯色背景 spritesheet;后端去背景正常成功后,再尝试自动拆分为可独立编辑的素材。 ## 入口与画布表现 @@ -28,7 +28,7 @@ 2. 第二模块为素材描述文本框。 - UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。 - 默认按换行填入:`返回按钮`、`设置按钮`、`下一关按钮`、`提示按钮`、`原图按钮`、`冻结按钮`。 - - 生成时按换行、逗号、顿号、分号、斜杠或竖线切分,过滤空文本后最多保留 `100` 个素材描述,并按文本顺序作为 prompt 的素材清单。 + - 生成时只去除整段文本首尾空白,不按换行、逗号、顿号、分号、斜杠、竖线或语义枚举解析素材数量;文本框内容作为一段完整用户需求进入 prompt。 ## 面板外观 @@ -41,7 +41,7 @@ - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 - 请求字段: - `referenceImageSrc`:图标规范的稳定引用(当前账号的 `objectKey`、项目资源 ID 或素材 ID);本地临时图必须先上传 OSS,禁止 Data URL / Blob URL。 - - `iconDescriptions`:过滤空文本后的图标描述数组,`1..100`。 + - `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量。 - `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。 - `aspectRatio`:按 `x:y` 展示,选项跟随模型。 - `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。 @@ -55,9 +55,9 @@ - Prompt 固定为: ```text -参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet: +参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字。根据以下用户需求生成图标素材并整理成一张 spritesheet;不同图标素材之间必须彼此分离并保留清晰间距,避免描边、底板、投影或装饰元素连接相邻图标: -<素材描述按中文顿号拼接> +<完整用户需求> ``` ## 像素风格后处理 @@ -74,10 +74,10 @@ ## 去背与保存 - 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。 -- 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 spritesheet 写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。 +- 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 spritesheet 写入 OSS、项目资源和账号素材库,再识别透明图集中全部有效 alpha 连通域并执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。 - 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,因为透明背景失败时不会进入拆分,但可与风格归一化或像素规整产生的通用 `warning` 并存。前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 -- 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。 -- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。 +- 响应通过 `iconImageSrcs` 返回成功切片素材。图标素材生成的自动拆分与手动 `拆分图集` 复用同一套全连通域识别、视觉阅读顺序和自动命名规则:识别多少个有效素材就返回多少个,依次命名为 `素材 N`;用户提示词及 `iconDescriptions` 数组长度都不控制切片数量。 +- 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。自动拆分和手动拆分共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。自动拆分失败以 `sliceWarning` 非阻断降级,手动拆分失败则返回接口错误。 ## 前端铺放规则 @@ -89,11 +89,11 @@ - 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。 - `图标规范 -> 从画布中选择` 只能选择图标规范图,点击普通图片或角色规范图不会绑定。 -- 默认 6 个素材描述会进入 prompt;用户在单个文本框中继续输入时最多解析 100 个素材描述。 +- 默认提示文本会完整进入 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、项目资源、图集画布项和切片画布项数量不得因此增加。 - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。 -- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的按描述命名的独立图标图层;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。 +- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。 - 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。 - 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 3fccec8de..94ffdba5c 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -20,7 +20,6 @@ use platform_image::{ generated_asset_sheets::{ GeneratedAssetSheetConnectedIcon, slice_generated_icon_spritesheet_all_by_connected_components, - slice_generated_icon_spritesheet_by_connected_components, }, }; use platform_oss::{ @@ -106,9 +105,9 @@ const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; -const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION: u32 = 4096; -const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS: u64 = 2048 * 2048; -const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES: usize = 64; +const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096; +const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048; +const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 64; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; @@ -4733,79 +4732,75 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( mime_type: image.mime_type.clone(), extension: image.extension.clone(), }; - let (icon_image_srcs, slice_warning) = - match slice_generated_icon_spritesheet_by_connected_components( - &slice_source, - &icon_descriptions, - ) { - Ok(icon_slices) => { - match persist_editor_spritesheet_slices( - state, - icon_slices, - PersistEditorSpritesheetSlicesInput { - owner_user_id: owner_user_id.clone(), - project_id: payload.project_id.clone(), - asset_folder_id: asset_folder_id.clone(), - source_resource_id: spritesheet_record - .resource - .as_ref() - .map(|resource| resource.resource_id.clone()), - task_id: generated.task_id.clone(), - group_task_id: None, - prompt: "自动拆分图集".to_string(), - actual_prompt: None, - model: "connected-components".to_string(), - provider: "Genarrative".to_string(), - generation_inputs: build_editor_derived_asset_generation_inputs( - "图集拆分", - "connected-components", - &spritesheet_record, - ), - path_kind: "icon-spritesheet-assets", - asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, - persistence_provider: "vector-engine", - }, - ) - .await - { - Ok(icon_image_srcs) => (icon_image_srcs, None), - Err(error) => { - let reason = error.body_text(); - tracing::warn!( - provider = "editor-icon-spritesheet", - operation = "persist_automatic_slices", - task_id = %generated.task_id, - reason = %reason, - "图标图集已持久化,但自动拆分素材持久化失败" - ); - ( - Vec::new(), - Some(EditorIconSpritesheetSliceWarningResponse { - code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, - reason, - }), - ) - } + let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all(&slice_source) { + Ok(icon_slices) => { + match persist_editor_spritesheet_slices( + state, + icon_slices, + PersistEditorSpritesheetSlicesInput { + owner_user_id: owner_user_id.clone(), + project_id: payload.project_id.clone(), + asset_folder_id: asset_folder_id.clone(), + source_resource_id: spritesheet_record + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()), + task_id: generated.task_id.clone(), + group_task_id: None, + prompt: "自动拆分图集".to_string(), + actual_prompt: None, + model: "connected-components".to_string(), + provider: "Genarrative".to_string(), + generation_inputs: build_editor_derived_asset_generation_inputs( + "图集拆分", + "connected-components", + &spritesheet_record, + ), + path_kind: "icon-spritesheet-assets", + asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, + persistence_provider: "vector-engine", + }, + ) + .await + { + Ok(icon_image_srcs) => (icon_image_srcs, None), + Err(error) => { + let reason = error.body_text(); + tracing::warn!( + provider = "editor-icon-spritesheet", + operation = "persist_automatic_slices", + task_id = %generated.task_id, + reason = %reason, + "图标图集已持久化,但自动拆分素材持久化失败" + ); + ( + Vec::new(), + Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, + reason, + }), + ) } } - Err(error) => { - let reason = error.to_string(); - tracing::warn!( - provider = "editor-icon-spritesheet", - operation = "detect_automatic_slices", - task_id = %generated.task_id, - reason = %reason, - "图标图集已持久化,但自动拆分未识别到全部素材" - ); - ( - Vec::new(), - Some(EditorIconSpritesheetSliceWarningResponse { - code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, - reason, - }), - ) - } - }; + } + Err(error) => { + let reason = error.body_text(); + tracing::warn!( + provider = "editor-icon-spritesheet", + operation = "detect_automatic_slices", + task_id = %generated.task_id, + reason = %reason, + "图标图集已持久化,但自动拆分未完成" + ); + ( + Vec::new(), + Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, + reason, + }), + ) + } + }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( @@ -4911,15 +4906,7 @@ pub async fn split_editor_icon_spritesheet( mime_type: reference.mime_type, extension: "png".to_string(), }; - validate_editor_icon_spritesheet_manual_source(&source)?; - let slices = - slice_generated_icon_spritesheet_all_by_connected_components(&source).map_err(|error| { - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ - "provider": "editor-icon-spritesheet-slicing", - "message": error.to_string(), - })) - })?; - validate_editor_icon_spritesheet_manual_slice_count(slices.len())?; + let slices = slice_editor_icon_spritesheet_all(&source)?; let prompt = source_resource .prompt .clone() @@ -4989,9 +4976,30 @@ pub async fn split_editor_icon_spritesheet( )) } -fn validate_editor_icon_spritesheet_manual_source( +fn slice_editor_icon_spritesheet_all( source: &DownloadedImage, -) -> Result<(), AppError> { +) -> Result, AppError> { + validate_editor_icon_spritesheet_source(source)?; + let slices = + slice_generated_icon_spritesheet_all_by_connected_components(source).map_err(|error| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": error.to_string(), + })) + })?; + if slices.is_empty() { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集中未识别到可拆分的独立素材。", + })), + ); + } + validate_editor_icon_spritesheet_slice_count(slices.len())?; + Ok(slices) +} + +fn validate_editor_icon_spritesheet_source(source: &DownloadedImage) -> Result<(), AppError> { let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice())) .with_guessed_format() .map_err(|error| { @@ -5006,7 +5014,7 @@ fn validate_editor_icon_spritesheet_manual_source( "message": format!("无法读取图集图片尺寸:{error}"), })) })?; - validate_editor_icon_spritesheet_manual_dimensions(width, height) + validate_editor_icon_spritesheet_dimensions(width, height) } async fn resolve_editor_manual_atlas_split_group_task_id( @@ -5050,16 +5058,13 @@ fn editor_manual_atlas_split_group_source_lookup_input( } } -fn validate_editor_icon_spritesheet_manual_dimensions( - width: u32, - height: u32, -) -> Result<(), AppError> { +fn validate_editor_icon_spritesheet_dimensions(width: u32, height: u32) -> Result<(), AppError> { let pixel_count = u64::from(width).saturating_mul(u64::from(height)); if width == 0 || height == 0 - || width > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION - || height > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION - || pixel_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS + || width > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION + || height > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION + || pixel_count > EDITOR_ICON_SPRITESHEET_MAX_PIXELS { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ @@ -5067,22 +5072,22 @@ fn validate_editor_icon_spritesheet_manual_dimensions( "message": "图集尺寸超过手动拆分限制。", "width": width, "height": height, - "maxDimension": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION, - "maxPixels": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS, + "maxDimension": EDITOR_ICON_SPRITESHEET_MAX_DIMENSION, + "maxPixels": EDITOR_ICON_SPRITESHEET_MAX_PIXELS, })), ); } Ok(()) } -fn validate_editor_icon_spritesheet_manual_slice_count(slice_count: usize) -> Result<(), AppError> { - if slice_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES { +fn validate_editor_icon_spritesheet_slice_count(slice_count: usize) -> Result<(), AppError> { + if slice_count > EDITOR_ICON_SPRITESHEET_MAX_SLICES { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集识别出的素材数量超过手动拆分限制。", "sliceCount": slice_count, - "maxSliceCount": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES, + "maxSliceCount": EDITOR_ICON_SPRITESHEET_MAX_SLICES, })), ); } @@ -7384,9 +7389,9 @@ fn build_editor_icon_spritesheet_prompt( screen_color: EditorScreenBackgroundColor, ) -> String { format!( - "参考图1的图标素材规范,{};禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}", + "参考图1的图标素材规范,{};禁止出现文字。根据以下用户需求生成图标素材并整理成一张 spritesheet;不同图标素材之间必须彼此分离并保留清晰间距,避免描边、底板、投影或装饰元素连接相邻图标:\n\n{}", editor_green_screen_asset_prompt_clause(screen_color), - icon_descriptions.join("、") + icon_descriptions.join("\n") ) } @@ -10281,13 +10286,13 @@ mod tests { } #[test] - fn manual_icon_spritesheet_limits_reject_large_images_and_excessive_slices() { - assert!(validate_editor_icon_spritesheet_manual_dimensions(2048, 2048).is_ok()); - assert!(validate_editor_icon_spritesheet_manual_dimensions(4096, 1024).is_ok()); - assert!(validate_editor_icon_spritesheet_manual_dimensions(2049, 2048).is_err()); - assert!(validate_editor_icon_spritesheet_manual_dimensions(4097, 1).is_err()); - assert!(validate_editor_icon_spritesheet_manual_slice_count(64).is_ok()); - assert!(validate_editor_icon_spritesheet_manual_slice_count(65).is_err()); + fn icon_spritesheet_limits_reject_large_images_and_excessive_slices() { + assert!(validate_editor_icon_spritesheet_dimensions(2048, 2048).is_ok()); + assert!(validate_editor_icon_spritesheet_dimensions(4096, 1024).is_ok()); + assert!(validate_editor_icon_spritesheet_dimensions(2049, 2048).is_err()); + assert!(validate_editor_icon_spritesheet_dimensions(4097, 1).is_err()); + assert!(validate_editor_icon_spritesheet_slice_count(64).is_ok()); + assert!(validate_editor_icon_spritesheet_slice_count(65).is_err()); } #[test] @@ -10362,7 +10367,7 @@ mod tests { } #[test] - fn editor_icon_spritesheet_prompt_uses_ordered_descriptions_and_size_tiers() { + fn editor_icon_spritesheet_prompt_preserves_user_descriptions() { let descriptions = vec![ "返回按钮".to_string(), "设置按钮".to_string(), @@ -10380,7 +10385,8 @@ mod tests { assert!( prompt.contains("素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光") ); - assert!(prompt.contains("返回按钮、设置按钮、下一关按钮")); + assert!(prompt.contains("返回按钮\n设置按钮\n下一关按钮")); + assert!(prompt.contains("不同图标素材之间必须彼此分离并保留清晰间距")); } #[test] @@ -10732,7 +10738,7 @@ mod tests { icon_image_srcs: Vec::new(), slice_warning: Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, - reason: "图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。".to_string(), + reason: "图集中未识别到可拆分的独立素材。".to_string(), }), prompt: "图标 prompt".to_string(), actual_prompt: Some("图标 prompt".to_string()), @@ -10759,7 +10765,7 @@ mod tests { ); assert_eq!( payload["sliceWarning"]["reason"], - json!("图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。") + json!("图集中未识别到可拆分的独立素材。") ); } @@ -11515,7 +11521,7 @@ mod tests { "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", "persist_editor_provider_source_resource", - "slice_generated_icon_spritesheet_by_connected_components", + "slice_editor_icon_spritesheet_all", "persist_editor_spritesheet_slices", ], ); diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs index 15d9f424b..c0f9cc8a1 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs @@ -22,5 +22,4 @@ pub use sheet::{ crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row, slice_generated_icon_spritesheet_all_by_connected_components, - slice_generated_icon_spritesheet_by_connected_components, }; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index 4d4b46119..bdd386e69 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -144,17 +144,6 @@ pub struct GeneratedAssetSheetConnectedIcon { const GENERATED_ICON_MIN_VISIBLE_PIXELS: u32 = 16; const GENERATED_ICON_MAX_MERGE_ITERATIONS: usize = 16; -pub fn slice_generated_icon_spritesheet_by_connected_components( - image: &crate::DownloadedImage, - icon_names: &[String], -) -> Result, GeneratedAssetSheetError> { - let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { - GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}")) - })?; - let source = apply_generated_asset_sheet_green_screen_alpha(source); - slice_generated_icon_spritesheet_rgba_by_connected_components(source, icon_names, false) -} - pub fn slice_generated_icon_spritesheet_all_by_connected_components( image: &crate::DownloadedImage, ) -> Result, GeneratedAssetSheetError> { @@ -162,7 +151,7 @@ pub fn slice_generated_icon_spritesheet_all_by_connected_components( GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}")) })?; let source = apply_generated_asset_sheet_green_screen_alpha(source); - slice_generated_icon_spritesheet_rgba_by_connected_components(source, &[], true) + slice_generated_icon_spritesheet_rgba_by_connected_components(source) } pub fn crop_generated_asset_sheet_view_edge_matte( @@ -176,8 +165,6 @@ pub fn crop_generated_asset_sheet_view_edge_matte( fn slice_generated_icon_spritesheet_rgba_by_connected_components( source: image::DynamicImage, - icon_names: &[String], - auto_name_all_components: bool, ) -> Result, GeneratedAssetSheetError> { let mut image = source.to_rgba8(); let (width, height) = image.dimensions(); @@ -189,44 +176,22 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components( } let mut components = detect_generated_icon_components_by_alpha(&image, width, height); - if components.len() < icon_names.len() - || (auto_name_all_components && generated_icon_alpha_fill_ratio(&image) > 0.92) - { + if generated_icon_alpha_fill_ratio(&image) > 0.92 { let foreground_image = build_generated_icon_spritesheet_foreground_image(&image, width, height); let foreground_components = detect_generated_icon_components_by_alpha(&foreground_image, width, height); - if !foreground_components.is_empty() - && (foreground_components.len() >= icon_names.len() - || auto_name_all_components && foreground_components.len() >= components.len()) - { + if !foreground_components.is_empty() && foreground_components.len() >= components.len() { image = foreground_image; components = foreground_components; } } - let mut components = normalize_generated_icon_components( - components, - width, - height, - icon_names.len(), - auto_name_all_components, - ); + let mut components = normalize_generated_icon_components(components, width, height); sort_generated_icon_components_in_visual_rows(&mut components); - let icon_names = if auto_name_all_components { - (1..=components.len()) - .map(|index| format!("素材 {index}")) - .collect::>() - } else { - icon_names.to_vec() - }; - if components.len() < icon_names.len() { - return Err(GeneratedAssetSheetError::invalid_request(format!( - "图标 spritesheet 连通域数量不足:需要 {} 个,实际 {} 个。", - icon_names.len(), - components.len() - ))); - } + let icon_names = (1..=components.len()) + .map(|index| format!("素材 {index}")) + .collect::>(); let mut icons = Vec::with_capacity(icon_names.len()); for (name, bounds) in icon_names.iter().zip(components.into_iter()) { @@ -297,17 +262,10 @@ fn normalize_generated_icon_components( components: Vec, width: u32, height: u32, - required_count: usize, - auto_name_all_components: bool, ) -> Vec { let merged_components = merge_generated_icon_related_components(components, width, height); - let filtered_components = filter_generated_icon_scrap_components( - merged_components, - width, - height, - required_count, - auto_name_all_components, - ); + let filtered_components = + filter_generated_icon_scrap_components(merged_components, width, height); filtered_components .into_iter() .map(|component| component.bounds) @@ -511,10 +469,8 @@ fn filter_generated_icon_scrap_components( components: Vec, width: u32, height: u32, - required_count: usize, - auto_name_all_components: bool, ) -> Vec { - if components.len() <= required_count.max(1) { + if components.len() <= 1 { return components; } let max_visible_pixels = components @@ -546,10 +502,7 @@ fn filter_generated_icon_scrap_components( }) .collect::>(); - if filtered.is_empty() - || required_count > 0 && filtered.len() < required_count - || !auto_name_all_components && filtered.len() < components.len().min(required_count) - { + if filtered.is_empty() { return components; } filtered @@ -721,15 +674,12 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_by_connected_components( - &source, - &["返回按钮".to_string(), "设置按钮".to_string()], - ) - .expect("icons should slice"); + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + .expect("icons should slice"); assert_eq!(icons.len(), 2); - assert_eq!(icons[0].name, "返回按钮"); - assert_eq!(icons[1].name, "设置按钮"); + assert_eq!(icons[0].name, "素材 1"); + assert_eq!(icons[1].name, "素材 2"); assert!(icons[0].width >= 16); assert!(icons[0].height >= 14); assert!(image::load_from_memory(icons[0].bytes.as_slice()).is_ok()); @@ -756,11 +706,8 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_by_connected_components( - &source, - &["左侧素材".to_string(), "右侧素材".to_string()], - ) - .expect("same-row icons should slice from left to right"); + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + .expect("same-row icons should slice from left to right"); assert_icon_contains_color(&icons[0], left_color); assert_icon_contains_color(&icons[1], right_color); @@ -801,45 +748,14 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_by_connected_components( - &source, - &[ - "第一行左侧".to_string(), - "第一行右侧".to_string(), - "第二行左侧".to_string(), - "第二行右侧".to_string(), - ], - ) - .expect("visual rows should slice in reading order"); + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + .expect("visual rows should slice in reading order"); for (icon, color) in icons.iter().zip(colors) { assert_icon_contains_color(icon, color); } } - #[test] - fn rejects_when_connected_components_are_fewer_than_icon_names() { - let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(48, 48, Rgba([0, 255, 0, 255])); - for y in 12..24 { - for x in 12..24 { - sheet.put_pixel(x, y, Rgba([240, 80, 80, 255])); - } - } - let source = crate::DownloadedImage { - bytes: encode_png(sheet), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let error = slice_generated_icon_spritesheet_by_connected_components( - &source, - &["返回按钮".to_string(), "设置按钮".to_string()], - ) - .expect_err("missing component should fail"); - - assert!(error.to_string().contains("连通域数量不足")); - } - #[test] fn slices_all_icon_spritesheet_components_with_auto_names() { let mut sheet: image::RgbaImage = @@ -901,15 +817,12 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let icons = slice_generated_icon_spritesheet_by_connected_components( - &source, - &["爱心".to_string(), "星星".to_string()], - ) - .expect("detached accents should merge into their nearby icon"); + let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source) + .expect("detached accents should merge into their nearby icon"); assert_eq!(icons.len(), 2); - assert_eq!(icons[0].name, "爱心"); - assert_eq!(icons[1].name, "星星"); + assert_eq!(icons[0].name, "素材 1"); + assert_eq!(icons[1].name, "素材 2"); assert!(icons[0].width >= 48); assert!(icons[0].height >= 56); } diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index b34449d44..0202a3d50 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -2652,7 +2652,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ referenceImageSrc: 'resource-icon-spec', - iconDescriptions: ['返回按钮', '设置按钮'], + iconDescriptions: ['返回按钮\n设置按钮'], model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index fbcf383f7..c568371b7 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -30,9 +30,7 @@ import { createSpecDialogDraft, createUiDesignGenerationDialogDraft, createVideoGenerationDialogDraft, - formatIconDescriptionsText, hideGeneratedLayerComposerAfterBlur, - parseIconDescriptionsText, updateCharacterAnimationDurationPanel, updateIconDescriptionsTextInDialog, updateSpecFormDialogValue, @@ -1179,17 +1177,9 @@ describe('ImageCanvasGenerationDialogModel', () => { status: 'idle', errorMessage: undefined, prompt: '剑\n盾、药水', - iconDescriptions: ['剑', '盾', '药水'], + iconDescriptions: [], }), ); - expect(parseIconDescriptionsText('剑,盾\n药水/宝箱|钥匙')).toEqual([ - '剑', - '盾', - '药水', - '宝箱', - '钥匙', - ]); - expect(formatIconDescriptionsText(['剑', '盾'])).toBe('剑\n盾'); }); it('updates character animation duration and composer visibility', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 50ff65755..c07931655 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -30,7 +30,6 @@ import { DEFAULT_VIDEO_WEB_SEARCH_ENABLED, EDITOR_IMAGE_DIMENSION_OPTIONS, EDITOR_IMAGE_MODEL_OPTIONS, - ICON_DESCRIPTION_LIMIT, IMAGE_MODEL_GPT_IMAGE_2, inferEditorImageAspectRatio, inferEditorImageSizeLabel, @@ -87,18 +86,6 @@ function resetFailedGenerationPanel(panel: QuickEditPanelState) { }; } -export function parseIconDescriptionsText(text: string): string[] { - return text - .split(/[\r\n,,、;;/|]+/u) - .map((description) => description.trim()) - .filter(Boolean) - .slice(0, ICON_DESCRIPTION_LIMIT); -} - -export function formatIconDescriptionsText(descriptions: string[]): string { - return descriptions.join('\n'); -} - function isSeedanceVideoModel(model: string | undefined) { const resolvedModel = model ?? DEFAULT_VIDEO_MODEL; return ( @@ -920,16 +907,17 @@ function restoreIconDescriptionsFromLayer( sourceLayer: CanvasLayer, sourceDialog?: CanvasGenerationDialogState | null, ): Omit { - const descriptions = parseIconDescriptionsText( + const prompt = findGenerationInputFieldValue(sourceLayer, ['素材描述']) ?? - sourceDialog?.prompt ?? - '', - ); + sourceDialog?.prompt ?? + sourceDialog?.iconDescriptions?.join('\n') ?? + ''; + const normalizedPrompt = prompt.trim(); return { ...draft, - prompt: descriptions.join('\n'), - iconDescriptions: descriptions.length - ? descriptions + prompt, + iconDescriptions: normalizedPrompt + ? [normalizedPrompt] : (sourceDialog?.iconDescriptions ?? draft.iconDescriptions), iconSpecReference: sourceDialog?.iconSpecReference ?? draft.iconSpecReference, @@ -1582,7 +1570,7 @@ export function updateIconDescriptionsTextInDialog( ? { ...resetFailedGenerationDialog(dialog), prompt: value, - iconDescriptions: parseIconDescriptionsText(value), + iconDescriptions: [], } : dialog; } diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 945ea953c..463def180 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -83,7 +83,6 @@ export const SPEC_GENERATION_COST = EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][ SPEC_GENERATION_IMAGE_SIZE ]; -export const ICON_DESCRIPTION_LIMIT = 100; export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = { gameName: '', gameCategories: '', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 6d4741c25..1784b7056 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -681,10 +681,10 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); - it('builds icon spritesheet plans with trimmed descriptions and references', () => { + it('builds icon spritesheet plans with the complete prompt and references', () => { const plan = buildIconSpritesheetGenerationSubmissionPlan({ mode: 'icon', - prompt: ' 返回按钮 \n\n设置按钮', + prompt: ' 返回按钮\n\n设置按钮 ', status: 'idle', imageModel: 'gpt-image-2', style: 'pixelArt', @@ -710,11 +710,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => { expect(plan).toEqual({ ok: true, - iconDescriptions: ['返回按钮', '设置按钮'], + iconDescriptions: ['返回按钮\n\n设置按钮'], input: { referenceImageSrc: 'data:image/png;base64,spec', referenceImageSrcs: ['data:image/png;base64,ref'], - iconDescriptions: ['返回按钮', '设置按钮'], + iconDescriptions: ['返回按钮\n\n设置按钮'], model: 'gpt-image-2', screenColor: 'auto', segModel: 'birefnet', @@ -723,7 +723,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { imageSize: '2K', }, generationInputs: { - fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }], + fields: [{ title: '素材描述', value: '返回按钮\n\n设置按钮' }], references: [ { title: '图标规范', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 443b2a0d4..f33d4f55f 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -39,7 +39,6 @@ import { DEFAULT_VIDEO_MODEL, DEFAULT_VIDEO_SOUND, DEFAULT_VIDEO_WEB_SEARCH_ENABLED, - ICON_DESCRIPTION_LIMIT, IMAGE_MODEL_GPT_IMAGE_2, normalizeEditorImageModel, resolveCharacterAnimationSourceImageSrc, @@ -596,17 +595,13 @@ export function buildIconSpritesheetGenerationSubmissionPlan( dialog: GenerateDialogState, nextGeneratedIndex = 1, ): IconSpritesheetGenerationSubmissionPlan { - const iconDescriptionSource = dialog.prompt.trim() - ? dialog.prompt.split(/[\r\n,,、;;/|]+/u) - : (dialog.iconDescriptions ?? []).some((description) => - description.trim(), - ) - ? (dialog.iconDescriptions ?? []) - : []; - const iconDescriptions = iconDescriptionSource - .map((description) => description.trim()) - .filter(Boolean) - .slice(0, ICON_DESCRIPTION_LIMIT); + const normalizedPrompt = + dialog.prompt.trim() || + (dialog.iconDescriptions ?? []) + .map((description) => description.trim()) + .filter(Boolean) + .join('\n'); + const iconDescriptions = normalizedPrompt ? [normalizedPrompt] : []; if (!dialog.iconSpecReference) { return { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 3e55a91d6..02de6372f 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -2329,7 +2329,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); }); - it('submits icon spritesheets, trims descriptions, and remembers the model', async () => { + it('submits icon spritesheets, trims the complete prompt, and remembers the model', async () => { generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, @@ -2351,7 +2351,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { initialDialog={{ id: 'dialog-icon', mode: 'icon', - prompt: ' 返回按钮 \n\n 设置按钮 ', + prompt: ' 返回按钮\n设置按钮 ', assetLabel: ' 复古操作图标 ', status: 'idle', composerOpen: true, @@ -2382,7 +2382,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect.objectContaining({ referenceImageSrc: 'generated-character-drafts/editor/generation-references/reference.png', - iconDescriptions: ['返回按钮', '设置按钮'], + iconDescriptions: ['返回按钮\n设置按钮'], model: 'gpt-image-2', assetLabel: '复古操作图标', canvasCompletion: expect.objectContaining({ diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 96ebc61d0..e40b1e752 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -75,10 +75,8 @@ import { calculateCharacterAnimationPrice, CHARACTER_ANIMATION_DURATION_OPTIONS, CHARACTER_ANIMATION_MODEL, - DEFAULT_ICON_DESCRIPTIONS, DEFAULT_IMAGE_MODEL, EDITOR_IMAGE_DIMENSION_OPTIONS, - ICON_DESCRIPTION_LIMIT, IMAGE_MODEL_GPT_IMAGE_2, isCanvasGenerationDialog, isQuickEditUnsupportedAssetKind, @@ -913,17 +911,6 @@ export function useImageCanvasGenerationWorkflow({ effectiveCharacterAnimationPanel.durationSeconds, ) : 0; - const iconDescriptionValues = - generateDialog?.mode === 'icon' - ? generateDialog.prompt.trim() - ? generateDialog.prompt - .split(/[\r\n,,、;;/|]+/u) - .map((description) => description.trim()) - .filter(Boolean) - .slice(0, ICON_DESCRIPTION_LIMIT) - : (generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS) - : DEFAULT_ICON_DESCRIPTIONS; - const closeGenerationTransientState = useCallback(() => { setIsSpecMenuOpen(false); setIsGenerationReferenceMenuOpen(false); @@ -2537,7 +2524,6 @@ export function useImageCanvasGenerationWorkflow({ setCharacterAnimationPanel: setEffectiveCharacterAnimationPanel, characterAnimationSourceLayer, characterAnimationPrice, - iconDescriptionValues, isSpecMenuOpen, setIsSpecMenuOpen, isGenerationReferenceMenuOpen, @@ -2646,7 +2632,6 @@ export function useImageCanvasGenerationWorkflow({ submitUiAssetExtraction, setEffectiveCharacterAnimationPanel, hideGeneratedLayerPanelAfterBlur, - iconDescriptionValues, isCharacterReferenceMenuOpen, isCharacterSpecMenuOpen, isIconSpecMenuOpen, From 8848a68fb05b1d272c0e35bd4266200a2f3187ab Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 05:42:08 +0000 Subject: [PATCH 03/17] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=83=8F=E7=B4=A0?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E6=AD=A5=E9=95=BF=E4=BC=B0=E7=AE=97=E5=88=86?= =?UTF-8?q?=E4=BD=8D=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将相邻边缘峰间距由中位数改为线性插值 P30。 同步更新像素规整技术方案和共享决策记录。 --- docs/project-memory/shared-memory/decision-log.md | 2 +- ...前端架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../crates/platform-image/src/pixel_art_snapper.rs | 13 ++++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 49892bd75..34c59460c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4515,7 +4515,7 @@ - 契约:普通图片 / 角色共用的图片生成请求和图标图集生成请求增加可选字符串 `style`,当前公开合法值为 `none / pixelArt`。省略、`null`、空字符串和 `none` 归一为内部 `None` 且不告警;未知字符串、或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `pixelArt` 时,按 `None` 继续原管线并返回 `unsupported-image-style` 通用告警;非字符串 JSON 返回 `400`。旧队列 payload 缺少字段时兼容为 `None`。 - UI 边界:只有普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 显示 `像素艺术` 勾选项;当前选择可进入已有生成器快照和请求 / 队列 payload,但不写入 `generationInputs`、素材元数据或新表。画布 Agent 和其它生成 / 编辑入口不开放该选项。 -- 处理边界:`PixelArt` 由 `platform-image` 的纯同步、纯内存 Rust 模块执行,不运行 Python、不访问 OSS / 数据库 / 画布。普通图片直接使用 provider 图;角色和图标必须等 BgFilter 成功并把 Alpha 回贴到 provider 原尺寸后,以 provider 平底原图分析网格、以透明 RGBA 图采样。固定参数为分析色数 16、Alpha 覆盖阈值 0.375、像素尺寸自动、无固定色板、K-means 最大采样 262144;单格 RGB 按 Alpha 加权,输出 Alpha 只为 0 / 255,逻辑低分辨率结果用 nearest 恢复交付尺寸并跳过 Lanczos。 +- 处理边界:`PixelArt` 由 `platform-image` 的纯同步、纯内存 Rust 模块执行,不运行 Python、不访问 OSS / 数据库 / 画布。普通图片直接使用 provider 图;角色和图标必须等 BgFilter 成功并把 Alpha 回贴到 provider 原尺寸后,以 provider 平底原图分析网格、以透明 RGBA 图采样。固定参数为分析色数 16、Alpha 覆盖阈值 0.375、像素尺寸自动、相邻边缘峰间距使用线性插值 P30 估算步长、无固定色板、K-means 最大采样 262144;单格 RGB 按 Alpha 加权,输出 Alpha 只为 0 / 255,逻辑低分辨率结果用 nearest 恢复交付尺寸并跳过 Lanczos。 - 执行边界:像素规整 CPU 工作使用进程级最大并发 2;取得并发许可的排队时间与实际处理时间共享最多 30 秒预算,同时不得晚于当前请求 deadline,最终取更早者。输入图片任一边上限为 10000 像素、总像素上限为 8294400;超限、排队超时或处理超时均按 best-effort 非致命降级,不持久化部分结果。 - 去背边界:不修改 BgFilter `flat` 参数、`cross_check`、fallback、Alpha 回贴和默认关闭 despill 的现有行为。BgFilter 最终失败时不运行像素规整;像素规整失败按 best-effort 非致命降级,保留进入该步骤前的图片并通过既有通用 `warning` 完成任务,不退款。 - 持久化边界:逻辑低分辨率图、像素化前后对比图、预览、诊断和报告一律不持久化;像素模式只替换原本即将上传的最终图片字节。普通图片、角色、图标的 OSS PUT、asset / project resource 和画布 item 数量必须与 `None` 模式完全一致;角色 / 图标最多因复用失败增加一次对已有 provider 对象的 OSS GET,不得增加 PUT、资源类型、画布项、队列类型或 schema 字段。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 7e9dde17b..0ca474eaa 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -36,7 +36,7 @@ - 普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 保存选择并随现有请求 / 队列 payload 传递;该字段不写入用户可见 `generationInputs`,也不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。 - `style` 是可选字符串兼容边界。省略、`null`、空字符串和 `"none"` 统一归一为内部 `None`,不返回告警;`"pixelArt"` 仅允许普通图片(`kind` 省略)与 `kind="character"`,图标图集请求单独允许该值。未知字符串或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `"pixelArt"` 时,按 `None` 完成原管线并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍是畸形请求并返回 `400`。旧 payload 缺少字段时等价于 `None`。 - `None` 必须保持现有生成、尺寸处理、BgFilter、上传、资源和画布链路不变。`PixelArt` 只增加父流程内的纯内存 Rust 后处理,不启动 Python 或独立服务,也不改变 BgFilter 的 `flat` 参数、Alpha 回贴、`cross_check`、fallback 或默认关闭 despill 的现有行为。 -- 普通图片在 provider 回图后,以同一张图同时作为网格分析源和 RGBA 采样源;角色与图标在 BgFilter 正常成功、现有 Alpha 蒙版回贴到 provider 原尺寸后执行双输入规整,其中网格分析源为带纯色背景的 provider 原图,RGBA 采样源为 Alpha 已回贴的透明图。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。 +- 普通图片在 provider 回图后,以同一张图同时作为网格分析源和 RGBA 采样源;角色与图标在 BgFilter 正常成功、现有 Alpha 蒙版回贴到 provider 原尺寸后执行双输入规整,其中网格分析源为带纯色背景的 provider 原图,RGBA 采样源为 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 色只负责网格识别,不限制最终输出色数。 - 逻辑低分辨率图只存在于内存,随后使用 nearest 恢复到该任务原有交付尺寸,并直接替换原本即将持久化的最终图片字节。像素模式不得再经过 Lanczos 或其它会重新引入软边的插值。角色和图标应复用 Alpha 回贴阶段已经读取的 provider 原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 diff --git a/server-rs/crates/platform-image/src/pixel_art_snapper.rs b/server-rs/crates/platform-image/src/pixel_art_snapper.rs index 0ff50cd64..8ca7635b8 100644 --- a/server-rs/crates/platform-image/src/pixel_art_snapper.rs +++ b/server-rs/crates/platform-image/src/pixel_art_snapper.rs @@ -31,6 +31,7 @@ const KMEANS_SEED: u64 = 42; const MAX_KMEANS_ITERATIONS: usize = 15; const PEAK_THRESHOLD_MULTIPLIER: f64 = 0.2; const PEAK_DISTANCE_FILTER: usize = 4; +const STEP_SIZE_PERCENTILE: f64 = 0.3; const WALKER_SEARCH_WINDOW_RATIO: f64 = 0.35; const WALKER_MIN_SEARCH_WINDOW: f64 = 2.0; const WALKER_STRENGTH_THRESHOLD: f64 = 0.5; @@ -117,6 +118,7 @@ struct SnapConfig { max_kmeans_iterations: usize, peak_threshold_multiplier: f64, peak_distance_filter: usize, + step_size_percentile: f64, walker_search_window_ratio: f64, walker_min_search_window: f64, walker_strength_threshold: f64, @@ -134,6 +136,7 @@ impl SnapConfig { max_kmeans_iterations: MAX_KMEANS_ITERATIONS, peak_threshold_multiplier: PEAK_THRESHOLD_MULTIPLIER, peak_distance_filter: PEAK_DISTANCE_FILTER, + step_size_percentile: STEP_SIZE_PERCENTILE, walker_search_window_ratio: WALKER_SEARCH_WINDOW_RATIO, walker_min_search_window: WALKER_MIN_SEARCH_WINDOW, walker_strength_threshold: WALKER_STRENGTH_THRESHOLD, @@ -596,7 +599,15 @@ fn estimate_step_size(profile: &[f64], config: SnapConfig) -> Option { .map(|pair| (pair[1] - pair[0]) as f64) .collect::>(); differences.sort_by(f64::total_cmp); - Some(differences[differences.len() / 2]) + let percentile_position = + (differences.len() - 1) as f64 * config.step_size_percentile.clamp(0.0, 1.0); + let lower_index = percentile_position.floor() as usize; + let upper_index = percentile_position.ceil() as usize; + let interpolation = percentile_position - lower_index as f64; + Some( + differences[lower_index] + + (differences[upper_index] - differences[lower_index]) * interpolation, + ) } fn resolve_step_sizes( From a6a6be23c9205a5cc763a58c656e2d93e5cd1c3d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 06:43:59 +0000 Subject: [PATCH 04/17] =?UTF-8?q?=E5=BC=80=E5=90=AF=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=9B=BE=E9=9B=86BgFilter=E4=BA=A4=E5=8F=89=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将生成图标素材的 flat 请求固定切换为 cross_check=on。 同步调整现有结构断言与编辑器、后端和共享决策文档。 --- docs/project-memory/shared-memory/decision-log.md | 10 ++++++++++ ...【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md | 4 ++-- server-rs/crates/api-server/src/editor_project.rs | 6 +++--- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 34c59460c..dbdfb3850 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,16 @@ --- +## 2026-07-29 图标图集 BgFilter 开启 cross-check + +- 背景:图标 spritesheet 的透明化需要提高主体内部孔洞、轮廓和相邻小图标边缘的交叉校验质量。 +- 决策:生成图标素材的 BgFilter `background_mode=flat` 请求固定显式传 `cross_check=on`,与角色形象和角色动作逐帧去背一致;UI 设计图素材提取及手动 complex 去背景继续传 `off`。该参数仍属于后端内部供应商策略,不进入前端 DTO 或外部 OpenAPI。 +- 边界:不修改 BgFilter fallback、Alpha 回贴、默认关闭 despill、图标切片、OSS / 资源 / 画布持久化和任务告警语义。 +- 验证方式:运行 `cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:rustfmt`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +--- + ## 2026-07-23 画布 Agent 工具生命周期统一经 object-safe trait 分派 - 背景:画布 Agent 八类工具的参数规范化、确认展示、计价与 worker payload、完成结果格式化和媒体投影分别在 `tool_args.rs`、`display_args.rs`、`api.rs`、`reconcile.rs` 重复按工具名分派;新增或调整工具时容易漏改其中一处。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 0ca474eaa..1c1aaeaf0 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -22,7 +22,7 @@ - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 -- 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 +- 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 - BgFilter 单次真实 provider attempt 不再使用独立固定 timeout,而由父子共同按 `attempt = N × est × 2` 运行时派生;一次逻辑调用的 `callBudgetMs = 2 × attempt + 1s`,从子 worker 取得 provider permit 后才开始计时。当前冻结 `N=16 / est=5000ms` 时为 `160s / 321s`。父侧继续管理父 job / request 总预算,为每个内部 RPC 单独派生 `maxQueueWaitMs`;排队只消耗该字段,不侵蚀 `callBudgetMs`,flat 还需预留阿里云和本地键色 fallback 时间。角色动作不再按本次实际帧数增加 attempt,`32 / 40 / 48` 帧使用同一公式。角色动画继续用 `buffer_unordered(frame_count.max(1))` 同时提交单帧逻辑调用,由唯一子 worker 保证健康进程内实际在飞的 provider 请求不超过 `N`、admission 不超过默认保险丝 `Q=2048`;父流程仍按“对应绿幕源图上传 OSS 并释放原帧字节 → 以 object key 调内部 worker / 按 object key 降级 → 父侧完成透明帧处理并落 OSS”连续组成无序在途流水线,允许响应乱序,并在收口时 collect / drain 全部已提交 frame future、按 `frameIndex` 恢复顺序。任一帧最终失败时仍先排空全部已启动请求,再使整个动作任务失败退款,不发布缺帧动画;最终图片处理、OSS、画布写回和计费始终属于父流程。 - 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS。透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,三类任务同时把 provider 原图作为第二个图层放在透明主结果右侧,图标和 UI 的实际拆分素材从 provider 原图右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为唯一主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d56a8a254..de57b63a2 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -247,7 +247,7 @@ npm run check:server-rs-ddd - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 - 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 -- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一通过唯一 loopback `bgfilter-worker` 调用 BgFilter provider。provider 配置继续使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 与 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,默认 base URL 为 `http://58.87.105.82/bgfilter`;单次 provider attempt 上限不再独立配置,由公式 `N × est × 2` 运行时派生,其中 `est = GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS`(默认 `5000`,依据为服务端高并发单图处理约 1-3s、网络约 3-5s),旧 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 已删除;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。父流程先把候选 `objectKey`、`resourceId` 或 `assetId` 解析为当前 owner 已登记的私有 OSS object key;BFF 入队前统一拒绝 `data:` / `blob:`,底层 resolver 在解析引用前再次拒绝内联媒体并完成登记状态与 owner 校验。父流程只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 与模式参数,不传图片字节或签名 URL,并同步等待子 worker 返回的受限图片二进制 body。子 worker 在每次真实 provider attempt 前签发短期 OSS URL,承担 admission 保险丝 `Q`(默认 `2048`,仅防连接风暴)、provider 并发 `N`(生产 `16`);排队 deadline 从 `Q` admission 时刻起算,完成 JSON 校验并进入 provider permit 等待队列时再取得队长快照,按 `min((队长+5)×est×2, maxQueueWaitMs)` 约束排队等待。子 worker 还负责严格最多两次顺序 attempt、结果校验和按 flat / complex 隔离的进程级熔断;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式,且只由子 worker 读写。手动去背景固定使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;complex provider 失败累计自身熔断,任意失败或自身熔断都直接返回父流程失败,不接 flat fallback,也不影响 flat 熔断。标准纯色背景四条链路固定使用 `background_mode=flat`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。父侧不重试已建立连接的内部 RPC,仅对 TCP 连接从未建立的失败按父预算有界退避重试(跨过 worker 重启与开机排序窗口,收到任何 HTTP 响应即停止);flat 两次 provider attempt 失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才继续“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以 object key 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,子 worker 已发出的 BgFilter provider 失败(含被剩余预算截短后发生的 timeout 与 response 阶段超时,这类失败不计入熔断但仍是审计候选)由进程级 `1024` 个审计任务硬上限保护,获准任务写入共享 tracking outbox 根目录下独立的 `bgfilter-worker/` 子目录并批量落库;满载、outbox 缺失、达到磁盘保护阈值或写盘失败时允许丢弃并记录指标,不回退逐条同步直写 SpacetimeDB。父侧阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)继续按通用外部 API 审计策略处理。真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。成功图片字节返回后,最终 Alpha / 尺寸恢复、OSS / asset object、画布写回、计费和父任务终态仍全部由父流程负责。 +- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一通过唯一 loopback `bgfilter-worker` 调用 BgFilter provider。provider 配置继续使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 与 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,默认 base URL 为 `http://58.87.105.82/bgfilter`;单次 provider attempt 上限不再独立配置,由公式 `N × est × 2` 运行时派生,其中 `est = GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS`(默认 `5000`,依据为服务端高并发单图处理约 1-3s、网络约 3-5s),旧 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 已删除;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。父流程先把候选 `objectKey`、`resourceId` 或 `assetId` 解析为当前 owner 已登记的私有 OSS object key;BFF 入队前统一拒绝 `data:` / `blob:`,底层 resolver 在解析引用前再次拒绝内联媒体并完成登记状态与 owner 校验。父流程只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 与模式参数,不传图片字节或签名 URL,并同步等待子 worker 返回的受限图片二进制 body。子 worker 在每次真实 provider attempt 前签发短期 OSS URL,承担 admission 保险丝 `Q`(默认 `2048`,仅防连接风暴)、provider 并发 `N`(生产 `16`);排队 deadline 从 `Q` admission 时刻起算,完成 JSON 校验并进入 provider permit 等待队列时再取得队长快照,按 `min((队长+5)×est×2, maxQueueWaitMs)` 约束排队等待。子 worker 还负责严格最多两次顺序 attempt、结果校验和按 flat / complex 隔离的进程级熔断;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式,且只由子 worker 读写。手动去背景固定使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;complex provider 失败累计自身熔断,任意失败或自身熔断都直接返回父流程失败,不接 flat fallback,也不影响 flat 熔断。标准纯色背景四条链路固定使用 `background_mode=flat`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成、图标 spritesheet 生成和角色动作逐帧去背传 `cross_check=on`,UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。父侧不重试已建立连接的内部 RPC,仅对 TCP 连接从未建立的失败按父预算有界退避重试(跨过 worker 重启与开机排序窗口,收到任何 HTTP 响应即停止);flat 两次 provider attempt 失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才继续“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以 object key 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,子 worker 已发出的 BgFilter provider 失败(含被剩余预算截短后发生的 timeout 与 response 阶段超时,这类失败不计入熔断但仍是审计候选)由进程级 `1024` 个审计任务硬上限保护,获准任务写入共享 tracking outbox 根目录下独立的 `bgfilter-worker/` 子目录并批量落库;满载、outbox 缺失、达到磁盘保护阈值或写盘失败时允许丢弃并记录指标,不回退逐条同步直写 SpacetimeDB。父侧阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)继续按通用外部 API 审计策略处理。真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。成功图片字节返回后,最终 Alpha / 尺寸恢复、OSS / asset object、画布写回、计费和父任务终态仍全部由父流程负责。 - BgFilter 连接复用、超时与动作帧流水线:`AppState` 分别复用父侧内部 worker HTTP Client 和子 worker 专用 BgFilter provider HTTP Client;父侧对一次逻辑调用至多让 worker 接收一次内部 RPC,不重试已建立连接后的失败;仅 TCP 连接从未建立时(worker 重启 / 开机排序窗口)按每轮重算 `maxQueueWaitMs` 的有界退避序列重连——增加的只是连接尝试次数,不产生第二次被接收的 RPC。重连配额按本进程是否已连通过 worker 分档:首连前(冷启动)flat 22.5s / complex 约 62.5s,首连后 flat ≤1.5s / complex 22.5s;每次重连计 `bgfilter_internal_connect_retry_total` 指标。子 worker 在同一个 `N` permit 内严格最多执行两次顺序 provider attempt。唯一子 worker 使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=N`(生产 `16`)限制真实 provider 在途数;`GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=Q` 降级为可选 admission 保险丝(默认 `2048`,仅防连接风暴,显式配置时必须 `>= N`)。超时全部由 `N` 与 `est` 运行时派生:单 attempt 上限 `N × est × 2`、调用预算 `callBudgetMs = 2 × attempt + 1s`(自取得 `N` permit 起算)、排队等待受 `min((provider 等待队列队长+5)×est×2, maxQueueWaitMs)` 双重上界(动态项充当自适应过载探测,超时带 `bound = estimate | parent` 标记),排队不侵蚀调用预算;`N` 与 `est` 必须同放共享 API 基础环境;请求携带的 `callBudgetMs` 只是父侧配置指纹,worker 比对后不一致只告警并计 `bgfilter_internal_call_budget_drift_total` 指标、始终以本进程公式值执行——发布调优 N / est 的新旧进程共存窗口不得误伤在途任务,持久漂移由部署脚本共享 env 对齐校验在启动前拦截。角色动作不再增加 `2000ms × 本次实际帧数`,`32 / 40 / 48` 帧使用相同公式。父侧按剩余绝对预算派生 `maxQueueWaitMs`(flat 扣除 `39s` 父侧预留(`37s` fallback + `2s` 传输窗),complex 只留 `2s` 传输窗;`<= 0` 时不发请求直接降级 / 失败),client timeout 取 `maxQueueWaitMs + callBudgetMs + 2s`;每次 attempt 前重新签发短期 OSS URL,剩余时间不足时不开始新的 attempt。父侧成功响应解码槽 `P = 8`。角色动作继续以 `buffer_unordered(frame_count.max(1))` 将全部单帧逻辑调用加入无序在途集合;返回结果携带原始帧序并在最终 collect / drain 全部已提交 Future 后排序,任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。单帧按“绿幕源图 owned 上传 OSS 并释放原帧 → 以 object key 调内部 worker / 按 object key 由父侧降级 → 父侧处理透明帧并落 OSS”流水化。角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 仍统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制;BgFilter provider 的 `N` 不占该 OSS permit,阿里云和本地处理既不占 OSS permit,也不受 `N / Q` 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。最终帧 HEAD 失败只重试 HEAD,不重复 PUT。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 201910da5..3fc8d6c7c 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -69,11 +69,11 @@ - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。 - 逻辑低分辨率图只存在内存,并以 nearest 恢复到图集原有交付尺寸;像素模式不再经过 Lanczos。实现应复用 Alpha 回贴阶段读取的 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 - 开启或关闭像素风格都保持现有 provider 原图、透明图集和实际成功切片的持久化与画布数量不变。禁止上传逻辑低分辨率图、像素化前后双份图集、预览或诊断图,也不新增 asset kind、项目资源、画布 item、任务类型或数据库字段。 -- 本功能不修改 BgFilter `flat` 调用、`cross_check=off`、fallback、Alpha 回贴或默认关闭 despill 的现状。BgFilter 最终失败时沿用只保留 provider 原图且不拆分的既有收口,像素规整不运行;像素规整自身失败时保留已成功的透明图并继续上传和拆分,通过通用 `warning` 非致命提示,不退款。`sliceWarning` 继续只表达透明图成功后的自动拆分失败,可与风格归一化或像素规整产生的通用 `warning` 并存。 +- 图标图集的 BgFilter `flat` 调用固定使用 `cross_check=on`,fallback、Alpha 回贴和默认关闭 despill 的行为保持不变。BgFilter 最终失败时沿用只保留 provider 原图且不拆分的既有收口,像素规整不运行;像素规整自身失败时保留已成功的透明图并继续上传和拆分,通过通用 `warning` 非致命提示,不退款。`sliceWarning` 继续只表达透明图成功后的自动拆分失败,可与风格归一化或像素规整产生的通用 `warning` 并存。 ## 去背与保存 -- 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。 +- 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。 - 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 spritesheet 写入 OSS、项目资源和账号素材库,再识别透明图集中全部有效 alpha 连通域并执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。 - 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,因为透明背景失败时不会进入拆分,但可与风格归一化或像素规整产生的通用 `warning` 并存。前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 - 响应通过 `iconImageSrcs` 返回成功切片素材。图标素材生成的自动拆分与手动 `拆分图集` 复用同一套全连通域识别、视觉阅读顺序和自动命名规则:识别多少个有效素材就返回多少个,依次命名为 `素材 N`;用户提示词及 `iconDescriptions` 数组长度都不控制切片数量。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 94ffdba5c..af2937217 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4577,7 +4577,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( source_object_key.as_str(), screen_color, seg_model, - EDITOR_BGFILTER_CROSS_CHECK_DISABLED, + EDITOR_BGFILTER_CROSS_CHECK_ENABLED, &matting_audit, ) .await; @@ -11519,7 +11519,7 @@ mod tests { "persist_editor_provider_source_image", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", - "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", + "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", "persist_editor_provider_source_resource", "slice_editor_icon_spritesheet_all", "persist_editor_spritesheet_slices", @@ -11534,7 +11534,7 @@ mod tests { "persist_editor_provider_source_resource", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", - "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", + "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", ], ); assert_function_contains_in_order( From ac9797b5c3afd6707646d386b3c7968ac963e189 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 08:15:43 +0000 Subject: [PATCH 05/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E5=9B=9E=E9=80=80=E4=B8=8E=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E9=87=8D=E5=A4=8D=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正图标生成恢复时空提示词对旧描述的阻断。 删除移动端生成按钮重复的宽度声明。 --- .../image-editor/ImageCanvasGenerationDialogModel.ts | 3 +-- src/index.css | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index c07931655..6c8019b3b 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -909,8 +909,7 @@ function restoreIconDescriptionsFromLayer( ): Omit { const prompt = findGenerationInputFieldValue(sourceLayer, ['素材描述']) ?? - sourceDialog?.prompt ?? - sourceDialog?.iconDescriptions?.join('\n') ?? + (sourceDialog?.prompt || sourceDialog?.iconDescriptions?.join('\n')) ?? ''; const normalizedPrompt = prompt.trim(); return { diff --git a/src/index.css b/src/index.css index fe4cf92e8..447de29b9 100644 --- a/src/index.css +++ b/src/index.css @@ -8945,10 +8945,6 @@ button.image-canvas-editor__reference-chip:disabled { justify-content: flex-start; } - .image-canvas-editor__generation-submit { - width: 100%; - } - .image-canvas-editor__character-composer { position: fixed; left: 0.75rem !important; From a97b12d73a84660fb5be37db4cb55d5f4a9bc794 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 08:22:41 +0000 Subject: [PATCH 06/17] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=83=8F=E7=B4=A0?= =?UTF-8?q?=E8=A7=84=E6=95=B4=E9=87=8D=E6=9E=84=E6=AE=8B=E7=95=99=E7=9A=84?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restore_editor_generated_image_output_dimensions 在本分支把 restore_editor_generated_image_output_dimensions_or_original 改为转发到 _with_filter 之后失去全部生产调用者,仅剩单测在用,非测试构建报 dead_code。 删除该包装函数,改由测试直接调用 _with_filter 并显式传 Lanczos3。 Co-Authored-By: Claude Opus 5 --- .../crates/api-server/src/editor_project.rs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a9f17e8f4..3134c8d00 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -3273,19 +3273,6 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ } } -fn restore_editor_generated_image_output_dimensions( - output: &DownloadedOpenAiImage, - model: &str, - target_size: &str, -) -> Result, AppError> { - restore_editor_generated_image_output_dimensions_with_filter( - output, - model, - target_size, - image::imageops::FilterType::Lanczos3, - ) -} - fn restore_editor_generated_image_output_dimensions_with_filter( output: &DownloadedOpenAiImage, model: &str, @@ -9183,10 +9170,11 @@ mod tests { mime_type: "image/png".to_string(), extension: "png".to_string(), }; - let restored = restore_editor_generated_image_output_dimensions( + let restored = restore_editor_generated_image_output_dimensions_with_filter( &provider_output, GPT_IMAGE_2_MODEL, "720x540", + image::imageops::FilterType::Lanczos3, ) .expect("provider output should restore delivery dimensions") .expect("mismatched provider output should require a transformation"); From 9655324dea0a7ae856706afe60aa7d7b1f777b00 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 09:47:17 +0000 Subject: [PATCH 07/17] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=94=9F=E6=88=90=20pa?= =?UTF-8?q?yload=20=E6=96=B0=E5=A2=9E=20style=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=9A=84=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useImageCanvasGenerationWorkflow.test.tsx 用 toHaveBeenCalledWith 精确匹配 普通图片生成 payload,本分支为该 payload 增加 style 字段后未同步该文件, CI 前端测试报 "+ style: none"。 该文件不在本分支改动清单内,故此前未被发现。 Co-Authored-By: Claude Opus 5 --- .../image-editor/useImageCanvasGenerationWorkflow.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 163e8ee9f..aaef28162 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1271,6 +1271,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '一张生成图', model: 'gemini-3.1-flash-image-preview', + style: 'none', aspectRatio: '1:1', imageSize: '1K', projectId: undefined, @@ -1352,6 +1353,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '一张生成图', model: 'gemini-3.1-flash-image-preview', + style: 'none', aspectRatio: '1:1', imageSize: '1K', projectId: undefined, From a7d8bccec9ad3a219f2ae21c1cea000680fecb7d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 11:19:01 +0000 Subject: [PATCH 08/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=83=8F=E7=B4=A0?= =?UTF-8?q?=E8=A7=84=E6=95=B4=E9=99=8D=E7=BA=A7=E7=BB=95=E8=BF=87=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E5=B0=BA=E5=AF=B8=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 像素路径的 best-effort 降级分支(预算耗尽、回读 provider 原图失败或超时、 CPU permit 获取失败、worker 内 deadline、join 异常、worker 超时)此前都直接 返回 BgFilter 原始输出并把尺寸错误置为 None,跳过非像素路径已有的尺寸比对 与 alpha 回贴。BgFilter 回图尺寸漂移叠加并发上限 2 的 permit 超时后,角色会 绕过原图安全降级,角色和图标都可能持久化尺寸漂移的低分辨率透明图。 新增 degrade_editor_pixel_art_to_postprocessed_with_dimension_guard 收口所有 降级分支,复用非像素路径的守卫:尺寸一致原样返回且不产生额外 OSS GET,漂移 才回读原图重贴 alpha,修复失败返回尺寸错误交调用方降级。由 provider 原图合成 的 rgba_source fallback 尺寸天然正确,不经守卫。 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 10 ++ .../crates/api-server/src/editor_project.rs | 163 +++++++++++++++--- 2 files changed, 150 insertions(+), 23 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 29d29c4f3..c2b9f4ad4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,16 @@ --- +## 2026-07-29 像素规整降级必须复用交付尺寸守卫 + +- 背景:像素模式接入「角色带背景原图与透明图统一交付尺寸」后,删除了原先像素路径末尾的后置尺寸恢复。但像素规整的 best-effort 降级分支(预算耗尽、回读 provider 原图失败或超时、CPU permit 获取失败、worker 内 deadline、join 异常、worker 超时)都直接返回 BgFilter 原始输出并把尺寸错误置为 `None`,跳过了非像素路径已有的尺寸比对与 alpha 回贴。BgFilter 回图尺寸漂移是已知现象,叠加并发上限 2 导致的 permit 超时后,角色会绕过「改用已保存的同尺寸原图完成画布」的安全降级,角色和图标都可能持久化尺寸漂移的低分辨率透明图。 +- 决策:像素路径的每一条降级都必须经 `degrade_editor_pixel_art_to_postprocessed_with_dimension_guard` 收口,该守卫复用非像素路径的 `apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original`:先做纯内存尺寸比对,与交付尺寸一致就原样返回且不产生额外 OSS GET;漂移才回读原图重贴 alpha;修复失败返回尺寸错误交由调用方降级。由 provider 原图逐像素合成的 `rgba_source` fallback 尺寸天然正确,不再经守卫。像素路径函数因此需要显式接收交付宽高。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色与图标像素规整降级路径;不改变成功路径、OSS PUT 次数、资源类型、画布项或前端契约,OSS GET 仍只在尺寸漂移时发生。 +- 验证方式:`pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions` 结构断言固定"降级分支不得返回 `(postprocessed, None, …)`"与守卫的委托实现;运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`、`npm run check:rustfmt`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:本文件「2026-07-29 角色带背景原图与透明图统一交付尺寸」与「2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发」。 + +--- + ## 2026-07-29 角色带背景原图与透明图统一交付尺寸 - 背景:图片画布已将模型原生回图归一到统一业务像素矩阵,但角色分支为了保留 provider 原生分辨率,先持久化带背景原图,只在扣背后归一透明主图。因此同一个 1K 角色任务会同时给出模型原生大图和长边 `1024` 的透明图。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index f36f40205..05a692290 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2028,6 +2028,8 @@ pub(crate) async fn generate_editor_image_for_owner( apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state, source_object_key.as_str(), + delivery_width, + delivery_height, removal.image, request_context.external_call_deadline(), ) @@ -3204,9 +3206,35 @@ fn resolve_editor_pixel_art_processing_deadline( .unwrap_or(local_deadline) } +// 中文注释:像素规整的每一条 best-effort 降级都不能直接把 BgFilter 原始输出当作 +// 最终结果——它的尺寸可能相对交付尺寸漂移。这里复用非像素路径的同一套守卫:先做 +// 纯内存尺寸比对,一致就原样返回且不产生 OSS GET;只有真的漂移才回读原图重贴 +// alpha,修不好则返回尺寸错误,由调用方走原图安全降级。 +async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( + state: &AppState, + provider_source_object_key: &str, + delivery_width: u32, + delivery_height: u32, + postprocessed: DownloadedOpenAiImage, + reason: String, +) -> (DownloadedOpenAiImage, Option, Option) { + let (image, dimension_error) = + apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( + state, + provider_source_object_key, + delivery_width, + delivery_height, + postprocessed, + ) + .await; + (image, dimension_error, Some(reason)) +} + async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state: &AppState, provider_source_object_key: &str, + delivery_width: u32, + delivery_height: u32, postprocessed: DownloadedOpenAiImage, request_deadline: Option, ) -> (DownloadedOpenAiImage, Option, Option) { @@ -3216,11 +3244,15 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ // BgFilter 前持久化并释放,因此这里只回读既有对象一次;这次回读同时承担 // 尺寸漂移时的 alpha 回贴,禁止为两个步骤分别发起 OSS GET。 if Instant::now() >= processing_deadline { - return ( + return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( + state, + provider_source_object_key, + delivery_width, + delivery_height, postprocessed, - None, - Some("像素规整处理预算已耗尽,已保留透明后处理图。".to_string()), - ); + "像素规整处理预算已耗尽,已保留透明后处理图。".to_string(), + ) + .await; } let provider_source = match tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), @@ -3231,19 +3263,41 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ Ok(Ok(provider_source)) => provider_source, Ok(Err(error)) => { let reason = error.body_text(); - return (postprocessed, None, Some(reason)); + return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( + state, + provider_source_object_key, + delivery_width, + delivery_height, + postprocessed, + reason, + ) + .await; } Err(_) => { - return ( + return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( + state, + provider_source_object_key, + delivery_width, + delivery_height, postprocessed, - None, - Some("像素规整读取 provider 原图超时,已保留透明后处理图。".to_string()), - ); + "像素规整读取 provider 原图超时,已保留透明后处理图。".to_string(), + ) + .await; } }; let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await { Ok(permit) => permit, - Err(error) => return (postprocessed, None, Some(error)), + Err(error) => { + return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( + state, + provider_source_object_key, + delivery_width, + delivery_height, + postprocessed, + error, + ) + .await; + } }; let provider_source = Arc::new(provider_source); @@ -3287,21 +3341,49 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await; match result { Ok(Ok(Ok(image))) => (image, None, None), - Ok(Ok(Err((fallback, preparation_error, error)))) => ( - fallback.unwrap_or_else(|| take_arc_downloaded_image(postprocessed)), - preparation_error, + // 中文注释:fallback 由 provider 原图逐像素合成,尺寸天然等于交付尺寸, + // 不需要再走守卫;准备阶段已产出尺寸错误时同样直接交回调用方降级。 + Ok(Ok(Err((Some(rgba_source), preparation_error, error)))) => { + (rgba_source, preparation_error, Some(error)) + } + Ok(Ok(Err((None, Some(preparation_error), error)))) => ( + take_arc_downloaded_image(postprocessed), + Some(preparation_error), Some(error), ), - Ok(Err(error)) => ( - take_arc_downloaded_image(postprocessed), - None, - Some(format!("像素规整工作线程异常:{error}")), - ), - Err(_) => ( - take_arc_downloaded_image(postprocessed), - None, - Some("像素规整处理超时,已保留透明后处理图。".to_string()), - ), + Ok(Ok(Err((None, None, error)))) => { + degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( + state, + provider_source_object_key, + delivery_width, + delivery_height, + take_arc_downloaded_image(postprocessed), + error, + ) + .await + } + Ok(Err(error)) => { + degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( + state, + provider_source_object_key, + delivery_width, + delivery_height, + take_arc_downloaded_image(postprocessed), + format!("像素规整工作线程异常:{error}"), + ) + .await + } + Err(_) => { + degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( + state, + provider_source_object_key, + delivery_width, + delivery_height, + take_arc_downloaded_image(postprocessed), + "像素规整处理超时,已保留透明后处理图。".to_string(), + ) + .await + } } } @@ -4630,6 +4712,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state, source_object_key.as_str(), + source_width, + source_height, removal.image, request_context.external_call_deadline(), ) @@ -9201,6 +9285,39 @@ mod tests { ); } + #[test] + fn pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions() { + let source = include_str!("editor_project.rs"); + // 中文注释:像素规整的每一条 best-effort 降级(预算耗尽、回读原图失败或超时、 + // permit 获取失败、worker 内 deadline、join 异常、worker 超时)都不能把 BgFilter + // 原始输出连同 None 尺寸错误直接交回调用方,否则角色会绕过原图安全降级、 + // 角色和图标都可能持久化尺寸漂移的低分辨率透明图。 + assert_function_not_contains( + source, + "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", + "fn restore_editor_generated_image_output_dimensions", + &[ + "return (postprocessed, None,", + "(postprocessed, None, Some(reason))", + "take_arc_downloaded_image(postprocessed),\n None,", + "fallback.unwrap_or_else(", + ], + ); + // 守卫必须复用非像素路径的同一套尺寸比对与 alpha 回贴,保证尺寸一致时 + // 不产生额外 OSS GET,漂移修不好时返回尺寸错误。 + assert_function_contains_in_order( + source, + "async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard", + "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", + &[ + "apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", + "delivery_width", + "delivery_height", + "(image, dimension_error, Some(reason))", + ], + ); + } + #[test] fn pixel_art_provider_input_prep_shares_deadline_and_cpu_permit() { let source = include_str!("editor_project.rs"); From 49965ddd7e2669111d5c4d2ebc2068a3899fd3f1 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 11:27:11 +0000 Subject: [PATCH 09/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=E6=81=A2=E5=A4=8D=E9=99=8D=E7=BA=A7=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=B7=B2=E7=B4=AF=E7=A7=AF=E7=94=9F=E6=88=90=E5=91=8A?= =?UTF-8?q?=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_editor_image_for_owner 的角色透明图尺寸恢复失败分支直接返回 warning: Some(...),没有合并 generation_warning,导致 style 为未知值时 契约承诺的 unsupported-image-style 告警在该降级路径上消失。 改为经 merge_editor_generation_warnings 合并,与该函数另外两个返回点一致。 新增结构断言固定"普通图片与图标生成的所有返回点都不得出现裸 warning: Some(", 并同步角色降级断言到合并后的形状。 Co-Authored-By: Claude Opus 5 --- .../crates/api-server/src/editor_project.rs | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 05a692290..9d6dd2861 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2082,10 +2082,15 @@ pub(crate) async fn generate_editor_image_for_owner( resource: source_record.resource, asset: source_record.asset, project: completed_project, - warning: Some(editor_postprocess_fallback_warning_with_dimension( - "生成任务成功,后处理失败。", - dimension_warning.as_ref(), - )), + // 中文注释:原图安全降级同样要保留此前累积的告警(例如未知 style 的 + // unsupported-image-style),不能被后处理降级告警整条覆盖。 + warning: merge_editor_generation_warnings( + generation_warning, + Some(editor_postprocess_fallback_warning_with_dimension( + "生成任务成功,后处理失败。", + dimension_warning.as_ref(), + )), + ), }, )); } @@ -9285,6 +9290,26 @@ mod tests { ); } + #[test] + fn editor_image_generation_returns_never_drop_accumulated_warnings() { + let source = include_str!("editor_project.rs"); + // 中文注释:generation_warning 承载未知 style 的 unsupported-image-style 等 + // 累积告警,普通图片与角色生成的每一个返回点都必须经 merge 合并;任何 + // 直接的 `warning: Some(` 都会把此前累积的告警整条覆盖掉。 + for (start, end) in [ + ( + "pub(crate) async fn generate_editor_image_for_owner", + "fn editor_image_generation_billing_asset_kind", + ), + ( + "async fn generate_editor_icon_spritesheet_for_owner", + "fn slice_editor_icon_spritesheet_all", + ), + ] { + assert_function_not_contains(source, start, end, &["warning: Some("]); + } + } + #[test] fn pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions() { let source = include_str!("editor_project.rs"); @@ -12229,7 +12254,11 @@ mod tests { "complete_editor_canvas_generation", "source_record.resource.as_ref()", "return Ok(json_success_body", - "warning: Some(editor_postprocess_fallback_warning", + // 中文注释:原图安全降级必须合并此前累积的告警,而不是整条覆盖, + // 同时仍要带上后处理降级告警本身。 + "warning: merge_editor_generation_warnings(", + "generation_warning", + "editor_postprocess_fallback_warning", ] { assert!( dimension_fallback_body.contains(snippet), From 8ed85e17f4c061cb01c3ed27cac4cfd36ed33717 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 11:44:27 +0000 Subject: [PATCH 10/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=98=9F=E5=88=97?= =?UTF-8?q?=E4=B8=8E=20Web=20=E5=90=9E=E6=8E=89=E5=B9=B6=E5=AD=98=E7=9A=84?= =?UTF-8?q?=20sliceWarning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 风格归一化与像素规整新增的通用 warning 可以与 sliceWarning 并存,openapi 已放开该组合,但两个消费端仍是二选一:队列 extract_editor_generation_warning 在 warning 存在时根本不读 sliceWarning,Web inline resolveEditorGenerationWarningMessage 同样直接返回通用告警。 两侧改为与 inline 响应相同的归一策略:只有一条时原样保留,两条并存时按 "通用在前、拆分在后"拼接,队列侧 code 收敛为 multiple-generation-warnings 并复用既有长度上界收敛。同步修正三处仍声明二者互斥的文档。 Co-Authored-By: Claude Opus 5 --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 2 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- .../crates/api-server/src/editor_project.rs | 2 +- .../src/external_generation_worker.rs | 75 ++++++++++++++++--- ...anvasGenerationSubmissionWorkflow.test.tsx | 59 +++++++++++++++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 11 ++- 7 files changed, 133 insertions(+), 20 deletions(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 622240e86..e520ad764 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -296,7 +296,7 @@ npm run check:server-rs-ddd - 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 legacy `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 素材写回:worker 成功后仍经 `api-server` facade 写入 `editor_project_resource` / `editor_asset`;结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 分流按上一条执行,前端不直接发明正式完成态。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 -- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 互斥。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`,且通用 `warning` 优先并原样保留完整 `reason`,只有 `sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 +- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 ### `external_generation_job_summary` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 038dc8ad6..d98f6279d 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -42,7 +42,7 @@ v1 只开放以下能力: - `POST /api/external/v1/editor/audios/background-music/generations`:生成编辑器背景音乐素材。 - `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。 -角色图生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`,当前稳定 `code` 为 `postprocess-failed-source-preserved`。provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 warning,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`;服务端保证通用 `warning` 与 `sliceWarning` 互斥,防御性客户端若收到异常双字段响应仍以通用 `warning` 为准。 +角色图生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`,当前稳定 `code` 为 `postprocess-failed-source-preserved`。provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 warning,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥(该情况不会进入拆分);2026-07-29 起风格归一化或像素规整会产生新的通用 `warning`,它可以与 `sliceWarning` 并存,调用方必须同时展示两者,不得只取其一。 管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON: diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index a3dfcd624..7daef9a08 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -123,7 +123,7 @@ - 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。 - 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 - 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。 -- 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,与 `sliceWarning` 互斥;`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 +- 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,该情况不会进入拆分,因此与 `sliceWarning` 互斥;风格归一化或像素规整产生的通用 `warning` 则可与 `sliceWarning` 并存,inline 与队列两条链路都必须把两者拼成同一条提示展示,不得只取通用告警。`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 - 画布顶部的生成 / 参考图选择 warning toast 保留手动关闭按钮,并在每次 warning 事件进入显示态后 `3` 秒自动消失,避免一次错误提示持续遮挡画布。同样文案在未消失时再次触发也必须重新计时,不能沿用上一次事件的剩余时间。 - 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板不展示“资源名称”输入,默认继续使用现有“类型 + 编号”名称;提示词输入保持统一可见边框。状态与请求契约仍兼容可选 `assetLabel`,内部调用或历史状态携带名称时最多 80 个字符并在提交时 trim,最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource`、`editor_asset` 和 `canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。 - 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9d6dd2861..ca45bb9ba 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -124,7 +124,7 @@ const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persisten const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-image-style"; -const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings"; +pub(crate) const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings"; const EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT: f64 = 0.05; const EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY: usize = 2; const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 4280c81ae..15e9ad98d 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -40,7 +40,8 @@ use crate::{ EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, }, editor_project::{ - EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationPhaseReporter, + EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EditorBackgroundRemovalRequest, + EditorGenerationCaller, EditorGenerationPhaseReporter, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner, @@ -1270,12 +1271,11 @@ fn compact_editor_generation_result(mut result: Value) -> Value { result } -fn extract_editor_generation_warning(response: &Value) -> Option { - let data = response.get("data").unwrap_or(response); - let (warning, is_slice_warning) = match data.get("warning") { - Some(warning) => (warning, false), - None => (data.get("sliceWarning")?, true), - }; +fn extract_editor_generation_warning_fields( + warning: Option<&Value>, + is_slice_warning: bool, +) -> Option<(String, String)> { + let warning = warning?; let code = warning.get("code")?.as_str()?.trim(); let reason = warning.get("reason")?.as_str()?.trim(); if code.is_empty() || reason.is_empty() { @@ -1286,6 +1286,29 @@ fn extract_editor_generation_warning(response: &Value) -> Option { } else { reason.to_string() }; + Some((code.to_string(), reason)) +} + +fn extract_editor_generation_warning(response: &Value) -> Option { + let data = response.get("data").unwrap_or(response); + // 中文注释:风格归一化和像素规整产生的通用 warning 可以与 sliceWarning 并存。 + // 队列结果只有一个有界 warning 字段,因此按与 inline 响应相同的策略归一: + // code 不同时收敛为 multiple-generation-warnings,reason 按“通用在前、拆分在后” + // 顺序拼接,再交给既有上界收敛,不允许其中任何一条被静默丢弃。 + let common = extract_editor_generation_warning_fields(data.get("warning"), false); + let slice = extract_editor_generation_warning_fields(data.get("sliceWarning"), true); + let (code, reason) = match (common, slice) { + (None, None) => return None, + (Some(warning), None) | (None, Some(warning)) => warning, + (Some((common_code, common_reason)), Some((slice_code, slice_reason))) => { + let code = if common_code == slice_code { + common_code + } else { + EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE.to_string() + }; + (code, format!("{common_reason} {slice_reason}")) + } + }; let reason = normalize_editor_generation_warning_reason(reason.as_str()); Some(json!({ "code": code, @@ -1846,18 +1869,18 @@ mod tests { } #[test] - fn editor_generation_result_payload_prefers_common_postprocess_warning() { + fn editor_generation_result_payload_merges_common_and_slice_warnings() { let job = external_generation_job_record_fixture(Some("lease-1")); let response = json!({ "data": { "imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", "warning": { - "code": "postprocess-failed-source-preserved", - "reason": "生成任务成功,后处理失败。" + "code": "unsupported-image-style", + "reason": "不支持的图片风格,已按无风格继续生成。" }, "sliceWarning": { "code": "insufficient-connected-components", - "reason": "不应覆盖通用后处理告警" + "reason": "有效连通域不足" } } }); @@ -1866,6 +1889,35 @@ mod tests { serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) .expect("worker 结果应是合法 JSON"); + // 中文注释:风格归一化告警与拆分告警可以并存,队列只有一个 warning 字段, + // 必须拼接后收敛 code,不能让其中任何一条消失。 + assert_eq!( + payload["warning"], + json!({ + "code": "multiple-generation-warnings", + "reason": "不支持的图片风格,已按无风格继续生成。 图集已生成,但自动拆分未完成:有效连通域不足" + }) + ); + assert!(payload.get("imageSrc").is_none()); + } + + #[test] + fn editor_generation_result_payload_keeps_single_warning_untouched() { + let job = external_generation_job_record_fixture(Some("lease-1")); + let response = json!({ + "data": { + "warning": { + "code": "postprocess-failed-source-preserved", + "reason": "生成任务成功,后处理失败。" + } + } + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("worker 结果应是合法 JSON"); + + // 中文注释:透明背景最终失败不会进入拆分,此时仍是单条告警,原样保留。 assert_eq!( payload["warning"], json!({ @@ -1873,7 +1925,6 @@ mod tests { "reason": "生成任务成功,后处理失败。" }) ); - assert!(payload.get("imageSrc").is_none()); } #[test] diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 02de6372f..038ecf126 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -2413,6 +2413,65 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); + it('keeps both the common warning and the slice warning when they coexist', async () => { + generateEditorIconSpritesheetMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,sheet', + spritesheetWidth: 512, + spritesheetHeight: 512, + prompt: '图标素材', + actualPrompt: '图标素材', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'task-icons', + iconImageSrcs: [], + warning: { + code: 'unsupported-image-style', + reason: '不支持的图片风格,已按无风格继续生成。', + }, + sliceWarning: { + code: 'insufficient-connected-components', + reason: '连通域数量不足', + }, + }); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交图标' })); + + // 风格归一化告警与拆分告警可以并存,两者都必须出现在同一条提示里。 + await waitFor(() => { + expect(screen.getByTestId('generation-warning').textContent).toBe( + '不支持的图片风格,已按无风格继续生成。 图集已生成,但自动拆分未完成:连通域数量不足', + ); + }); + }); + it('submits uploaded objectKey icon references before submitting spritesheets', async () => { generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index af859f2d3..ff4df781d 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -457,14 +457,17 @@ function resolveEditorGenerationWarningMessage( warning: string | null | undefined, sliceWarning: string | null | undefined, ) { + // 风格归一化和像素规整产生的通用 warning 可以与 sliceWarning 并存, + // 这里按“通用在前、拆分在后”拼接成单条提示,不允许其中任何一条被丢弃。 const commonReason = warning?.trim(); - if (commonReason) { - return commonReason; - } const sliceReason = sliceWarning?.trim(); - return sliceReason + const prefixedSliceReason = sliceReason ? `${EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX}${sliceReason}` : undefined; + if (commonReason && prefixedSliceReason) { + return `${commonReason} ${prefixedSliceReason}`; + } + return commonReason || prefixedSliceReason; } async function runEditorGenerationWithWalletRefresh( From 414735efde27ed4f8d169aaf36a60c0f2aed8b49 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 12:01:50 +0000 Subject: [PATCH 11/17] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=89=A9=E4=BD=99?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E7=9A=84=E5=8F=8C=E5=91=8A=E8=AD=A6=E5=B9=B6?= =?UTF-8?q?=E5=AD=98=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部编辑器 API 技能文档、外部生成 Worker 化方案和图片画布编辑器 MVP 接入方案仍写着通用 warning 与 sliceWarning 互斥、并以通用告警优先,与 已放开并存的 openapi 契约和 8ed85e17f 的队列 / inline 归一实现不一致。 四处统一改为:仅 postprocess-failed-source-preserved 与 sliceWarning 互斥 (该失败不进入拆分),风格归一化与像素规整产生的通用 warning 可与 sliceWarning 并存且两条都必须展示;同步补充队列侧的拼接与 code 收敛规则。 UI 设计图素材提取不接受 style 参数也不走像素规整,其文档中的互斥表述 仍然成立,未改动。 Co-Authored-By: Claude Opus 5 --- .codex/skills/genarrative-external-editor-api/SKILL.md | 2 +- .../genarrative-external-editor-api/references/api-selection.md | 2 +- .../【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index bca5a3cbd..36cc861fd 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -324,7 +324,7 @@ Character image generation (including character redraw through `kind: "character - Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation. - `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason. -- The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning. +- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because a failed transparent post-process never reaches slicing. Since 2026-07-29 a general `warning` may also come from image-style normalization (`unsupported-image-style`) or pixel-art snapping, and those can coexist with `sliceWarning` in the same response. Display both reasons; do not drop either one and do not misclassify a source-preserved result as a slicing-only warning. ## Guardrails diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index ad5232b9f..94742bbce 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -84,7 +84,7 @@ Character image generation (including character redraw through `kind: "character - Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch. - `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning. -- The service contract keeps `warning` and `sliceWarning` mutually exclusive. If a malformed response contains both, prioritize the general `warning` over `sliceWarning` defensively. +- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that failure never reaches slicing. A general `warning` produced by image-style normalization (`unsupported-image-style`) or pixel-art snapping can coexist with `sliceWarning`; render both reasons instead of picking one. ## Reference Image Upload diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 792db3f8b..69b084831 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -24,7 +24,7 @@ - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 - BgFilter 单次真实 provider attempt 不再使用独立固定 timeout,而由父子共同按 `attempt = N × est × 2` 运行时派生;一次逻辑调用的 `callBudgetMs = 2 × attempt + 1s`,从子 worker 取得 provider permit 后才开始计时。当前冻结 `N=16 / est=5000ms` 时为 `160s / 321s`。父侧继续管理父 job / request 总预算,为每个内部 RPC 单独派生 `maxQueueWaitMs`;排队只消耗该字段,不侵蚀 `callBudgetMs`,flat 还需预留阿里云和本地键色 fallback 时间。角色动作不再按本次实际帧数增加 attempt,`32 / 40 / 48` 帧使用同一公式。角色动画继续用 `buffer_unordered(frame_count.max(1))` 同时提交单帧逻辑调用,由唯一子 worker 保证健康进程内实际在飞的 provider 请求不超过 `N`、admission 不超过默认保险丝 `Q=2048`;父流程仍按“对应绿幕源图上传 OSS 并释放原帧字节 → 以 object key 调内部 worker / 按 object key 降级 → 父侧完成透明帧处理并落 OSS”连续组成无序在途流水线,允许响应乱序,并在收口时 collect / drain 全部已提交 frame future、按 `frameIndex` 恢复顺序。任一帧最终失败时仍先排空全部已启动请求,再使整个动作任务失败退款,不发布缺帧动画;最终图片处理、OSS、画布写回和计费始终属于父流程。 -- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS。透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,三类任务同时把 provider 原图作为第二个图层放在透明主结果右侧,图标和 UI 的实际拆分素材从 provider 原图右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为唯一主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 +- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS。透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,三类任务同时把 provider 原图作为第二个图层放在透明主结果右侧,图标和 UI 的实际拆分素材从 provider 原图右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为唯一主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因。它与 `sliceWarning` 只在 `postprocess-failed-source-preserved` 这一条上互斥(透明背景最终失败不会进入拆分);风格归一化和像素规整产生的通用 `warning` 可与 `sliceWarning` 并存,此时 inline 与队列两条链路都必须按“通用在前、拆分在后”拼成同一条提示展示,不得只取其一。既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把归一后的字符串交给 BFF `warning` 由前端直接展示。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 7937809cb..d5839a983 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -207,7 +207,7 @@ controller 配置: 透明背景处理正常成功时,角色形象、图标 spritesheet 和 UI 素材提取的画布都同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧;图标和 UI 实际拆分出的业务素材从 provider 原图右侧继续排列。 -inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:通用 `warning` 优先并原样保留完整 `reason`;只有不存在通用 `warning` 时,才给 `sliceWarning.reason` 添加“图集已生成,但自动拆分未完成:”前缀。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 +inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`;两条并存时按“通用在前、拆分在后”拼接,`code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),任何一条都不得被丢弃。`sliceWarning.reason` 无论是否与通用告警并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 ## 验收 From 937378ab91f1b8f3a2f08159a7af4c6924085140 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 12:29:42 +0000 Subject: [PATCH 12/17] =?UTF-8?q?=E6=B6=88=E9=99=A4=E5=83=8F=E7=B4=A0?= =?UTF-8?q?=E8=A7=84=E6=95=B4=E9=99=8D=E7=BA=A7=E8=B7=AF=E5=BE=84=E7=9A=84?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E4=B8=8E=E6=97=A0=E7=95=8C=20OSS=20=E5=9B=9E?= =?UTF-8?q?=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 尺寸守卫此前一律走会回读 provider 原图的包装函数,带来两个问题:permit 获取失败、worker 内 deadline、join 异常和 worker 超时这四条分支其实已经 持有原图,却又对同一 object key 发起第二次 GET,把"最多增加一次 OSS GET" 放大成两次;而第一次回读超时后的那次 GET 完全没有绝对 deadline,突破 30 秒像素预算。 拆成两类:已持有原图的四条分支改走纯内存守卫 degrade_editor_pixel_art_ with_provider_source,零额外 GET;尚未取得原图的三条分支仍走回读守卫, 但该次 GET 改以外层请求 deadline 为绝对上界——用已耗尽的像素预算绑会让 修复必然失败,不绑则突破预算。 apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original 新增可选 download_deadline,非像素路径传 None 保持既有语义;尺寸比对抽为 editor_postprocessed_alpha_matches_delivery_dimensions 供两类守卫共用。 计数断言固定"回读守卫 3 处、内存守卫 4 处",该断言在编写时即抓出一处 分支数误判。 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 1 + .../crates/api-server/src/editor_project.rs | 237 +++++++++++++----- 2 files changed, 173 insertions(+), 65 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c2b9f4ad4..938a77df2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -23,6 +23,7 @@ - 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色与图标像素规整降级路径;不改变成功路径、OSS PUT 次数、资源类型、画布项或前端契约,OSS GET 仍只在尺寸漂移时发生。 - 验证方式:`pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions` 结构断言固定"降级分支不得返回 `(postprocessed, None, …)`"与守卫的委托实现;运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`、`npm run check:rustfmt`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:本文件「2026-07-29 角色带背景原图与透明图统一交付尺寸」与「2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发」。 +- 补充(同日):守卫的回读必须分两类处理,否则会把「最多增加一次 OSS GET」放大成两次、且第二次无界。已取得 provider 原图的四条降级分支(permit 获取失败、worker 内 deadline、join 异常、worker 超时)改走纯内存守卫 `degrade_editor_pixel_art_with_provider_source`,零额外 GET;尚未取得原图的三条分支(进函数即预算耗尽、第一次回读失败、第一次回读超时)才走会回读的守卫,且该次 GET 以**外层请求 deadline**(而非已耗尽的像素预算)为绝对上界——用像素预算绑会让修复必然失败,不绑则突破预算。`apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original` 因此新增可选 `download_deadline`,非像素路径传 `None` 保持既有语义不变。计数断言固定「回读守卫 3 处、内存守卫 4 处」,防止后续新增分支时误用回读版本。 --- diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ca45bb9ba..9011cfb43 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2042,6 +2042,7 @@ pub(crate) async fn generate_editor_image_for_owner( delivery_width, delivery_height, removal.image, + None, ) .await; (image, error, None) @@ -3082,14 +3083,24 @@ fn apply_editor_postprocessed_alpha_to_provider_source_with_policy( })) } -async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( - state: &AppState, - provider_source_object_key: &str, - provider_width: u32, - provider_height: u32, +// 中文注释:已经持有 provider 原图时的纯内存尺寸守卫,不发起任何 OSS 请求。 +fn apply_editor_postprocessed_alpha_with_provider_source_or_original( + provider_source: &DownloadedOpenAiImage, postprocessed: DownloadedOpenAiImage, ) -> (DownloadedOpenAiImage, Option) { - let postprocessed_dimensions = + match apply_editor_postprocessed_alpha_to_provider_source(provider_source, &postprocessed) { + Ok(Some(restored)) => (restored, None), + Ok(None) => (postprocessed, None), + Err(error) => (postprocessed, Some(error)), + } +} + +fn editor_postprocessed_alpha_matches_delivery_dimensions( + postprocessed: &DownloadedOpenAiImage, + provider_width: u32, + provider_height: u32, +) -> Result { + let (postprocessed_width, postprocessed_height) = image::ImageReader::new(Cursor::new(postprocessed.bytes.as_slice())) .with_guessed_format() .map_err(|error| { @@ -3105,28 +3116,55 @@ async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_orig "message": format!("无法读取透明后处理图尺寸:{error}"), })) }) - }); - match postprocessed_dimensions { - Ok((postprocessed_width, postprocessed_height)) - if postprocessed_width == provider_width && postprocessed_height == provider_height => - { - return (postprocessed, None); - } - Ok(_) => {} + })?; + Ok(postprocessed_width == provider_width && postprocessed_height == provider_height) +} + +async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( + state: &AppState, + provider_source_object_key: &str, + provider_width: u32, + provider_height: u32, + postprocessed: DownloadedOpenAiImage, + download_deadline: Option, +) -> (DownloadedOpenAiImage, Option) { + match editor_postprocessed_alpha_matches_delivery_dimensions( + &postprocessed, + provider_width, + provider_height, + ) { + Ok(true) => return (postprocessed, None), + Ok(false) => {} Err(error) => return (postprocessed, Some(error)), } // 中文注释:provider 原图已在 BgFilter 调用前交给 OSS 并释放;只有输出尺寸漂移时才短时回读原图回贴 alpha。 - let provider_source = - match download_editor_persisted_image_object(state, provider_source_object_key).await { - Ok(provider_source) => provider_source, - Err(error) => return (postprocessed, Some(error)), - }; - match apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) { - Ok(Some(restored)) => (restored, None), - Ok(None) => (postprocessed, None), - Err(error) => (postprocessed, Some(error)), - } + // download_deadline 为像素规整降级路径提供绝对上界,避免在预算已耗尽后仍发起无界 GET; + // 非像素路径沿用既有语义传 None。 + let download = download_editor_persisted_image_object(state, provider_source_object_key); + let downloaded = match download_deadline { + Some(deadline) => { + match tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), download).await + { + Ok(downloaded) => downloaded, + Err(_) => Err( + AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({ + "provider": "editor-image-postprocess", + "message": "回读 provider 原图超时,已保留透明后处理图。", + })), + ), + } + } + None => download.await, + }; + let provider_source = match downloaded { + Ok(provider_source) => provider_source, + Err(error) => return (postprocessed, Some(error)), + }; + apply_editor_postprocessed_alpha_with_provider_source_or_original( + &provider_source, + postprocessed, + ) } fn take_arc_downloaded_image(image: Arc) -> DownloadedOpenAiImage { @@ -3221,8 +3259,12 @@ async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( delivery_width: u32, delivery_height: u32, postprocessed: DownloadedOpenAiImage, + request_deadline: Option, reason: String, ) -> (DownloadedOpenAiImage, Option, Option) { + // 中文注释:只有尚未成功取得 provider 原图的降级分支才会走到这里。像素预算此时 + // 多半已经耗尽,不能拿它去绑这次回读(否则必然失败、守卫形同虚设),改用外层 + // 请求 deadline 作为绝对上界,保证这唯一一次 GET 有界。 let (image, dimension_error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, @@ -3230,11 +3272,39 @@ async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( delivery_width, delivery_height, postprocessed, + request_deadline, ) .await; (image, dimension_error, Some(reason)) } +// 中文注释:provider 原图已在内存里的降级分支走这里,零额外 OSS GET, +// 保证「最多因复用失败增加一次 GET」的不变式不被降级路径打破。 +fn degrade_editor_pixel_art_with_provider_source( + provider_source: &DownloadedOpenAiImage, + delivery_width: u32, + delivery_height: u32, + postprocessed: DownloadedOpenAiImage, + reason: String, +) -> (DownloadedOpenAiImage, Option, Option) { + match editor_postprocessed_alpha_matches_delivery_dimensions( + &postprocessed, + delivery_width, + delivery_height, + ) { + Ok(true) => (postprocessed, None, Some(reason)), + Ok(false) => { + let (image, dimension_error) = + apply_editor_postprocessed_alpha_with_provider_source_or_original( + provider_source, + postprocessed, + ); + (image, dimension_error, Some(reason)) + } + Err(error) => (postprocessed, Some(error), Some(reason)), + } +} + async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state: &AppState, provider_source_object_key: &str, @@ -3255,6 +3325,7 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ delivery_width, delivery_height, postprocessed, + request_deadline, "像素规整处理预算已耗尽,已保留透明后处理图。".to_string(), ) .await; @@ -3274,6 +3345,7 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ delivery_width, delivery_height, postprocessed, + request_deadline, reason, ) .await; @@ -3285,6 +3357,7 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ delivery_width, delivery_height, postprocessed, + request_deadline, "像素规整读取 provider 原图超时,已保留透明后处理图。".to_string(), ) .await; @@ -3293,15 +3366,13 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await { Ok(permit) => permit, Err(error) => { - return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( - state, - provider_source_object_key, + return degrade_editor_pixel_art_with_provider_source( + &provider_source, delivery_width, delivery_height, postprocessed, error, - ) - .await; + ); } }; @@ -3356,39 +3427,27 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_ Some(preparation_error), Some(error), ), - Ok(Ok(Err((None, None, error)))) => { - degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( - state, - provider_source_object_key, - delivery_width, - delivery_height, - take_arc_downloaded_image(postprocessed), - error, - ) - .await - } - Ok(Err(error)) => { - degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( - state, - provider_source_object_key, - delivery_width, - delivery_height, - take_arc_downloaded_image(postprocessed), - format!("像素规整工作线程异常:{error}"), - ) - .await - } - Err(_) => { - degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( - state, - provider_source_object_key, - delivery_width, - delivery_height, - take_arc_downloaded_image(postprocessed), - "像素规整处理超时,已保留透明后处理图。".to_string(), - ) - .await - } + Ok(Ok(Err((None, None, error)))) => degrade_editor_pixel_art_with_provider_source( + provider_source.as_ref(), + delivery_width, + delivery_height, + take_arc_downloaded_image(postprocessed), + error, + ), + Ok(Err(error)) => degrade_editor_pixel_art_with_provider_source( + provider_source.as_ref(), + delivery_width, + delivery_height, + take_arc_downloaded_image(postprocessed), + format!("像素规整工作线程异常:{error}"), + ), + Err(_) => degrade_editor_pixel_art_with_provider_source( + provider_source.as_ref(), + delivery_width, + delivery_height, + take_arc_downloaded_image(postprocessed), + "像素规整处理超时,已保留透明后处理图。".to_string(), + ), } } @@ -4731,6 +4790,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( source_width, source_height, removal.image, + None, ) .await; (image, error, None) @@ -5506,6 +5566,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( source_width, source_height, removal.image, + None, ) .await; if let Some(error) = postprocess_dimension_error { @@ -9328,6 +9389,45 @@ mod tests { "fallback.unwrap_or_else(", ], ); + // 中文注释:像素函数里只有「尚未取得 provider 原图」的三条分支(进函数即预算耗尽、 + // 第一次回读失败、第一次回读超时)允许走会回读的守卫;取得原图之后的四条分支 + // (permit 获取失败、worker 内 deadline、join 异常、worker 超时)必须走纯内存守卫, + // 否则单次请求会对同一 object key 发出两次 GET,突破「最多增加一次 GET」的不变式。 + let pixel_fn_start = source + .find("async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original(") + .expect("pixel art entry should exist"); + let pixel_fn_body = &source[pixel_fn_start..]; + let pixel_fn_body = &pixel_fn_body[..pixel_fn_body + .find("fn restore_editor_generated_image_output_dimensions_with_filter") + .expect("pixel art entry should be followed by the dimension restore helper")]; + assert_eq!( + pixel_fn_body + .matches("degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(") + .count(), + 3, + "只有未取得 provider 原图的三条分支可以走回读守卫" + ); + assert_eq!( + pixel_fn_body + .matches("degrade_editor_pixel_art_with_provider_source(") + .count(), + 4, + "取得 provider 原图后的四条降级分支必须走纯内存守卫" + ); + // 纯内存守卫本身不得触碰 OSS。 + assert_function_not_contains( + source, + "fn degrade_editor_pixel_art_with_provider_source", + "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", + &["download_editor_persisted_image_object"], + ); + // 会回读原图的守卫必须带绝对 deadline 参数,不允许无界 GET。 + assert_function_contains_in_order( + source, + "async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard", + "fn degrade_editor_pixel_art_with_provider_source", + &["request_deadline: Option", "request_deadline,"], + ); // 守卫必须复用非像素路径的同一套尺寸比对与 alpha 回贴,保证尺寸一致时 // 不产生额外 OSS GET,漂移修不好时返回尺寸错误。 assert_function_contains_in_order( @@ -12326,17 +12426,24 @@ mod tests { "async fn persist_editor_provider_source_resource", &["persist_editor_generated_image_owned"], ); + // 中文注释:尺寸一致时必须在回读 provider 原图之前就返回,只有漂移才发起 GET。 assert_function_contains_in_order( source, "async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", "fn restore_editor_generated_image_output_dimensions", &[ - "into_dimensions", - "return (postprocessed, None)", + "editor_postprocessed_alpha_matches_delivery_dimensions", + "Ok(true) => return (postprocessed, None)", "download_editor_persisted_image_object", - "apply_editor_postprocessed_alpha_to_provider_source", + "apply_editor_postprocessed_alpha_with_provider_source_or_original", ], ); + assert_function_contains_in_order( + source, + "fn editor_postprocessed_alpha_matches_delivery_dimensions", + "async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", + &["into_dimensions"], + ); for (start, end) in [ ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", From bc486aaff5bbde7074917993729697569ba6725e Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 12:39:51 +0000 Subject: [PATCH 13/17] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=83=8F=E7=B4=A0=E8=A7=84=E6=95=B4=E4=B8=8E?= =?UTF-8?q?=E5=A4=96=E9=83=A8=20API=20=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步角色与图片画布文档的 Lanczos、去背和像素规整执行顺序 补全外部 OpenAPI 四个稳定 warning code 的语义 为外部编辑器 skill 补充顶层 style 参数、兼容规则和请求示例 --- .../genarrative-external-editor-api/SKILL.md | 2 ++ .../references/api-selection.md | 33 +++++++++++++++++-- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 6 ++-- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 9 ++++- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 4 +-- 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index 36cc861fd..ceabba443 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -115,6 +115,8 @@ python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_extern ## Request Patterns +For image and icon generation, the request-body top-level `style` field controls deterministic post-processing and is distinct from `generationInputs.artSpec.style`, which describes visual style for prompting. Pass `style="pixelArt"` in Python or `"style": "pixelArt"` in JSON to enable pixel-art snapping on supported generation types; use `"none"` or omit the field otherwise. Verify compatibility and fallback semantics in `references/api-selection.md`. + For Python callers, prefer: ```python diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index 94742bbce..42cf6325e 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -67,15 +67,44 @@ Ask a follow-up only when two routes could both be correct and produce different | User intent | Endpoint | Required fields | Common optional fields | | --- | --- | --- | --- | -| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | +| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` | -| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` | +| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` | | Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | | Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame | | Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Generate background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | +## Image Post-processing Style + +The request-body top-level `style` field controls deterministic image post-processing. It is separate from `generationInputs.artSpec.style`, which only describes the requested visual language for prompting. + +- Omitted, `null`, an empty string, and `"none"` all disable post-processing without a warning. +- `"pixelArt"` enables deterministic pixel-art snapping for ordinary image generation (omit `kind`), `kind: "character"`, and icon spritesheet generation. +- Unknown strings, or `"pixelArt"` on unsupported image kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`, continue without style processing and return `warning.code: "unsupported-image-style"`. +- A non-string JSON value is malformed and returns HTTP `400`. Keep the field extensible; do not treat the current examples as a closed client-side enum. + +Image or character generation with pixel-art snapping: + +```json +{ + "prompt": "生成一个正面站立的像素风冒险者角色", + "kind": "character", + "style": "pixelArt" +} +``` + +Icon spritesheet generation with pixel-art snapping: + +```json +{ + "referenceImageSrc": "generated-character-drafts/editor/external-editor-references/icon-spec.png", + "iconDescriptions": ["木剑", "圆盾", "红色药水"], + "style": "pixelArt" +} +``` + All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`. ## HTTP 2xx Warning Handling diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 69b084831..d7422fe6f 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -36,10 +36,10 @@ - 普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 保存选择并随现有请求 / 队列 payload 传递;该字段不写入用户可见 `generationInputs`,也不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。 - `style` 是可选字符串兼容边界。省略、`null`、空字符串和 `"none"` 统一归一为内部 `None`,不返回告警;`"pixelArt"` 仅允许普通图片(`kind` 省略)与 `kind="character"`,图标图集请求单独允许该值。未知字符串或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `"pixelArt"` 时,按 `None` 完成原管线并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍是畸形请求并返回 `400`。旧 payload 缺少字段时等价于 `None`。 - `None` 必须保持现有生成、尺寸处理、BgFilter、上传、资源和画布链路不变。`PixelArt` 只增加父流程内的纯内存 Rust 后处理,不启动 Python 或独立服务,也不改变 BgFilter 的 `flat` 参数、Alpha 回贴、`cross_check`、fallback 或默认关闭 despill 的现有行为。 -- 普通图片在 provider 回图后,以同一张图同时作为网格分析源和 RGBA 采样源;角色与图标在 BgFilter 正常成功、现有 Alpha 蒙版回贴到 provider 原尺寸后执行双输入规整,其中网格分析源为带纯色背景的 provider 原图,RGBA 采样源为 Alpha 已回贴的透明图。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、相邻边缘峰间距使用线性插值 `P30` 估算步长、固定色板关闭、K-means 最大采样 `262144`。 +- 普通图片与角色在 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 色只负责网格识别,不限制最终输出色数。 -- 逻辑低分辨率图只存在于内存,随后使用 nearest 恢复到该任务原有交付尺寸,并直接替换原本即将持久化的最终图片字节。像素模式不得再经过 Lanczos 或其它会重新引入软边的插值。角色和图标应复用 Alpha 回贴阶段已经读取的 provider 原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 +- 逻辑低分辨率图只存在于内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,并直接替换原本即将持久化的最终图片字节。普通图片和角色的该输入已经过前置 Lanczos 交付尺寸归一,或在无法安全归一时保留 provider 实际尺寸;图标输入以已持久化平底原图的实际尺寸为准。nearest 不替代前置尺寸归一,规整完成后不再执行第二次 Lanczos 或其它尺寸恢复。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 - 像素模式的持久化增量必须为零:普通图片仍只上传原有一张最终主图;角色仍只保留原有 provider 原图与透明主图;图标仍只保留原有 provider 原图、透明图集和实际成功的切片。禁止保存逻辑低分辨率图、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 - 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 @@ -135,7 +135,7 @@ - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图再次改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。 - 普通图片、角色和图标面板显示 `像素艺术` 勾选项并正确提交 / 恢复 `style: "none" | "pixelArt"`;其它生成或编辑面板不显示该选项。旧 payload、未知字符串、不支持 `kind` 和非字符串输入分别按本方案约定的兼容或错误语义处理。 -- `pixelArt` 输出 Alpha 只包含 `0 / 255`,使用 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 不得把安全移动误判为素材替换。生成结果必须在加入画布前写入生成历史,自动适合视图不得覆盖这条栈顶记录。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index d98f6279d..19b2f5de9 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -42,7 +42,14 @@ v1 只开放以下能力: - `POST /api/external/v1/editor/audios/background-music/generations`:生成编辑器背景音乐素材。 - `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。 -角色图生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`,当前稳定 `code` 为 `postprocess-failed-source-preserved`。provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 warning,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥(该情况不会进入拆分);2026-07-29 起风格归一化或像素规整会产生新的通用 `warning`,它可以与 `sliceWarning` 并存,调用方必须同时展示两者,不得只取其一。 +图片生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`。外部 OpenAPI 当前公开四个稳定 `code`: + +- `postprocess-failed-source-preserved`:生成成功,但透明处理、像素规整等后处理未完成,接口保留仍可使用的原图或进入该步骤前的结果。 +- `dimension-restore-fallback`:provider 回图无法安全收口到目标交付尺寸,接口保留实际回图尺寸。 +- `unsupported-image-style`:请求的图片后处理风格未知或不适用于当前生成类型,接口按无风格继续生成。 +- `multiple-generation-warnings`:同一成功响应合并了不同 `code` 的多条非阻断告警,具体原因按顺序拼接在 `reason`。 + +provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 `warning.reason`,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥(该情况不会进入拆分);风格归一化或像素规整产生的通用 `warning` 可以与 `sliceWarning` 并存,调用方必须同时展示两者,不得只取其一。 管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON: diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 069c60404..c705be75a 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -65,10 +65,10 @@ - 角色面板增加紧凑的 `像素艺术` 勾选项,请求使用可选字符串字段 `style`:未勾选传 `"none"`,勾选传 `"pixelArt"`。该选择可以随现有生成器快照和队列 payload 保存,但不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 - `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 在 `kind="character"` 时启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。同一图片生成请求 DTO 被其它 `kind` 复用时,只有普通图片和 `character` 支持 `"pixelArt"`,其它 `kind` 收到该值也按不支持风格降级。 -- 像素规整位于 BgFilter 正常成功且现有 Alpha 蒙版已经回贴到 provider 原尺寸之后、最终尺寸处理和透明主图上传之前。网格分析源使用已有的带纯色背景 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;软 Alpha 只参与单格覆盖率和 Alpha 加权 RGB 计算,输出 Alpha 硬化为 `0 / 255`。 +- 角色 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 已回贴的透明图并走非致命降级。 -- 逻辑低分辨率图只存在内存,并以 nearest 恢复到角色任务原有交付尺寸,像素模式不再经过 Lanczos。实现应复用 Alpha 回贴阶段读取的 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 +- 逻辑低分辨率图只存在内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,该输入已经是前述 Lanczos 归一后的交付尺寸,或尺寸归一无法安全执行时保留的 provider 实际尺寸。nearest 不是新的交付尺寸归一,规整完成后也不再执行第二次 Lanczos 或其它尺寸恢复。实现应复用 Alpha 回贴阶段读取的已持久化平底原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 - 开启或关闭像素风格都保持现有 provider 原图与透明主图两份产物、项目资源和画布图层数量不变。禁止上传逻辑低分辨率图、像素化前后双份主图、预览或诊断图,也不新增 asset kind、画布 item 或任务类型。 - 本功能不修改 BgFilter `flat` 调用、`cross_check=on`、fallback、Alpha 回贴或默认关闭 despill 的现状。BgFilter 最终失败时沿用只保留 provider 原图的既有收口且不运行像素规整;像素规整自身失败时保留已成功的透明图并继续原有持久化,通过通用 `warning` 非致命提示,不退款。 From fefea643aa264fd9b2ac6ae3b9c0fed073e3e620 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 12:42:05 +0000 Subject: [PATCH 14/17] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E8=A7=84=E6=95=B4=E4=B8=93=E9=A2=98=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确图标图集以 provider 原图实际尺寸完成 Alpha 回贴与像素规整 删除规整后仍有独立最终尺寸处理的旧描述 说明 snapper 内部 nearest 与角色前置 Lanczos 的边界 --- docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index ea05e8f28..fcb3a4387 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -65,10 +65,10 @@ - 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 - `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。 -- 像素规整位于 BgFilter 正常成功且现有 Alpha 蒙版已经回贴到 provider 原尺寸之后、透明 spritesheet 最终尺寸处理和上传之前。网格分析源使用已有的带纯色背景 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;像素规整成功后才进入原有连通域自动拆分。 +- 图标链路以已持久化的带纯色背景 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 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。 -- 逻辑低分辨率图只存在内存,并以 nearest 恢复到图集原有交付尺寸;像素模式不再经过 Lanczos。实现应复用 Alpha 回贴阶段读取的 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 +- 逻辑低分辨率图只存在内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,即前述平底 provider 原图的实际尺寸。图标链路不执行角色链路的前置 Lanczos 交付尺寸归一,nearest 也不是规整后的独立交付尺寸恢复。实现应复用 Alpha 回贴阶段读取的平底 provider 原图;必要时最多增加一次读取已有 provider 对象的 OSS GET,不得增加 OSS PUT。 - 开启或关闭像素风格都保持现有 provider 原图、透明图集和实际成功切片的持久化与画布数量不变。禁止上传逻辑低分辨率图、像素化前后双份图集、预览或诊断图,也不新增 asset kind、项目资源、画布 item、任务类型或数据库字段。 - 图标图集的 BgFilter `flat` 调用固定使用 `cross_check=on`,fallback、Alpha 回贴和默认关闭 despill 的行为保持不变。BgFilter 最终失败时沿用只保留 provider 原图且不拆分的既有收口,像素规整不运行;像素规整自身失败时保留已成功的透明图并继续上传和拆分,通过通用 `warning` 非致命提示,不退款。`sliceWarning` 继续只表达透明图成功后的自动拆分失败,可与风格归一化或像素规整产生的通用 `warning` 并存。 From 9a37deeaf08d4816849e9f0d5a315c8143d2895b Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 12:56:37 +0000 Subject: [PATCH 15/17] =?UTF-8?q?=E6=94=B6=E7=AA=84=E5=83=8F=E7=B4=A0?= =?UTF-8?q?=E8=A7=84=E6=95=B4=E5=B0=BA=E5=AF=B8=E5=AE=88=E5=8D=AB=E7=9A=84?= =?UTF-8?q?=E7=94=9F=E6=95=88=E8=8C=83=E5=9B=B4=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 决策记录原文写「修复失败返回尺寸错误交由调用方降级」,读起来像是角色和 图标两条链路都会降级。实际上守卫只保证像素路径不再谎报「尺寸无误」, 拿到尺寸错误之后如何处置仍由各调用方既有语义决定,本次未改动任何调用方。 改为显式写明生效范围:角色据此走原图安全降级、链路闭环;图标沿用 master 既有的非致命语义,只记录告警后继续持久化并拆分,因此 BgFilter 尺寸漂移且 回贴修复失败时仍可能持久化尺寸异常的透明图集,该残留属 master 既有行为, 不在本次范围内解决。守卫断言的注释同步收窄。 仅调整表述,不改代码行为,不新增承诺。 Co-Authored-By: Claude Opus 5 --- docs/project-memory/shared-memory/decision-log.md | 3 ++- server-rs/crates/api-server/src/editor_project.rs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 938a77df2..0371e37b8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -19,7 +19,8 @@ ## 2026-07-29 像素规整降级必须复用交付尺寸守卫 - 背景:像素模式接入「角色带背景原图与透明图统一交付尺寸」后,删除了原先像素路径末尾的后置尺寸恢复。但像素规整的 best-effort 降级分支(预算耗尽、回读 provider 原图失败或超时、CPU permit 获取失败、worker 内 deadline、join 异常、worker 超时)都直接返回 BgFilter 原始输出并把尺寸错误置为 `None`,跳过了非像素路径已有的尺寸比对与 alpha 回贴。BgFilter 回图尺寸漂移是已知现象,叠加并发上限 2 导致的 permit 超时后,角色会绕过「改用已保存的同尺寸原图完成画布」的安全降级,角色和图标都可能持久化尺寸漂移的低分辨率透明图。 -- 决策:像素路径的每一条降级都必须经 `degrade_editor_pixel_art_to_postprocessed_with_dimension_guard` 收口,该守卫复用非像素路径的 `apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original`:先做纯内存尺寸比对,与交付尺寸一致就原样返回且不产生额外 OSS GET;漂移才回读原图重贴 alpha;修复失败返回尺寸错误交由调用方降级。由 provider 原图逐像素合成的 `rgba_source` fallback 尺寸天然正确,不再经守卫。像素路径函数因此需要显式接收交付宽高。 +- 决策:像素路径的每一条降级都必须经 `degrade_editor_pixel_art_to_postprocessed_with_dimension_guard` 收口,该守卫复用非像素路径的 `apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original`:先做纯内存尺寸比对,与交付尺寸一致就原样返回且不产生额外 OSS GET;漂移才回读原图重贴 alpha;修复失败如实返回尺寸错误,由调用方按各自既有语义处理。由 provider 原图逐像素合成的 `rgba_source` fallback 尺寸天然正确,不再经守卫。像素路径函数因此需要显式接收交付宽高。 +- 生效范围(不承诺超出这一范围):本决策只保证像素路径不再谎报「尺寸无误」,即不再把 BgFilter 原始输出连同 `None` 尺寸错误交回调用方。拿到尺寸错误之后怎么处理仍由各调用方既有语义决定,本次不改变任何调用方语义:角色生成会据此走「改用已保存的同尺寸原图完成画布」的安全降级,因此角色链路闭环;图标图集沿用 master 既有的非致命语义,只记录告警后继续持久化并拆分,因此在「BgFilter 尺寸漂移且回贴修复失败」时,图标仍可能持久化尺寸异常的透明图集——该残留属于 master 既有行为,未在本次范围内解决。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色与图标像素规整降级路径;不改变成功路径、OSS PUT 次数、资源类型、画布项或前端契约,OSS GET 仍只在尺寸漂移时发生。 - 验证方式:`pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions` 结构断言固定"降级分支不得返回 `(postprocessed, None, …)`"与守卫的委托实现;运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`、`npm run check:rustfmt`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:本文件「2026-07-29 角色带背景原图与透明图统一交付尺寸」与「2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发」。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9011cfb43..e694ce906 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -9376,8 +9376,10 @@ mod tests { let source = include_str!("editor_project.rs"); // 中文注释:像素规整的每一条 best-effort 降级(预算耗尽、回读原图失败或超时、 // permit 获取失败、worker 内 deadline、join 异常、worker 超时)都不能把 BgFilter - // 原始输出连同 None 尺寸错误直接交回调用方,否则角色会绕过原图安全降级、 - // 角色和图标都可能持久化尺寸漂移的低分辨率透明图。 + // 原始输出连同 None 尺寸错误直接交回调用方——那等于谎报「尺寸无误」。 + // 本断言只覆盖「如实上报尺寸错误」这一层:拿到错误后如何处置仍由各调用方 + // 既有语义决定,角色据此走原图安全降级,图标沿用 master 的非致命语义(只告警、 + // 继续持久化与拆分),后者不在本断言的保证范围内。 assert_function_not_contains( source, "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", From bc28f00c2dc5fd7cba430beabc4ead91e12094f5 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 13:12:21 +0000 Subject: [PATCH 16/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20inline=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=BF=AE=E5=A4=8D=E6=80=A7=E5=9B=9E=E8=AF=BB=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=E7=BB=9D=E5=AF=B9=20deadline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮把修复性回读绑到外层 request_deadline,但 with_external_call_deadline 只在队列 worker 与 openai_image_generation 两处调用,inline HTTP 请求的 RequestContext 默认 external_call_deadline=None,于是 download_deadline 传下去 仍是 None,走无界 download.await——该修复实际只对队列模式生效。 守卫改为在 request_deadline 缺失时自行以 Instant::now() + EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION 重新计时派生上界,两种模式下都不再 出现无界 GET。结构断言同时要求出现 unwrap_or_else 兜底与该常量。 同时修正决策记录里「最多增加一次 OSS GET」的措辞:约束是不重复读取已成功 取得的对象,而非整个请求至多一次 GET;第一次回读失败或被像素预算掐断时 允许第二次也是最后一次尝试,不视为违规。 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 4 ++- .../crates/api-server/src/editor_project.rs | 25 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0371e37b8..f8d6fa58f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -24,7 +24,9 @@ - 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色与图标像素规整降级路径;不改变成功路径、OSS PUT 次数、资源类型、画布项或前端契约,OSS GET 仍只在尺寸漂移时发生。 - 验证方式:`pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions` 结构断言固定"降级分支不得返回 `(postprocessed, None, …)`"与守卫的委托实现;运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`、`npm run check:rustfmt`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:本文件「2026-07-29 角色带背景原图与透明图统一交付尺寸」与「2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发」。 -- 补充(同日):守卫的回读必须分两类处理,否则会把「最多增加一次 OSS GET」放大成两次、且第二次无界。已取得 provider 原图的四条降级分支(permit 获取失败、worker 内 deadline、join 异常、worker 超时)改走纯内存守卫 `degrade_editor_pixel_art_with_provider_source`,零额外 GET;尚未取得原图的三条分支(进函数即预算耗尽、第一次回读失败、第一次回读超时)才走会回读的守卫,且该次 GET 以**外层请求 deadline**(而非已耗尽的像素预算)为绝对上界——用像素预算绑会让修复必然失败,不绑则突破预算。`apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original` 因此新增可选 `download_deadline`,非像素路径传 `None` 保持既有语义不变。计数断言固定「回读守卫 3 处、内存守卫 4 处」,防止后续新增分支时误用回读版本。 +- 补充(同日):守卫的回读必须分两类处理。已取得 provider 原图的四条降级分支(permit 获取失败、worker 内 deadline、join 异常、worker 超时)改走纯内存守卫 `degrade_editor_pixel_art_with_provider_source`,零额外 GET;尚未取得原图的三条分支(进函数即预算耗尽、第一次回读失败、第一次回读超时)才走会回读的守卫。计数断言固定「回读守卫 3 处、内存守卫 4 处」,防止后续新增分支时误用回读版本。 +- OSS 回读口径(修正此前「最多增加一次 OSS GET」的措辞):约束是**不重复读取已经成功取得的对象**,而不是"整个请求至多一次 GET"。仅在尺寸漂移且尚未持有原图时才发起最多一次修复性回读,失败后不再重试;因此第一次回读失败或被像素预算掐断时,允许存在第二次、也是最后一次尝试——第一次超时往往并非 OSS 异常,而是被 30 秒像素预算切断,此时对象通常可正常读取,放弃修复反而会让角色更频繁地退化为原图单产物。 +- 回读上界:修复性回读必须始终有绝对 deadline。优先取外层 `request_deadline`,但它只在队列 worker 路径上有值——inline HTTP 请求的 `RequestContext` 默认 `external_call_deadline = None`,此时守卫自行以 `Instant::now() + EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION` 重新计时派生上界,不得退化为无界 `download.await`。`apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original` 的可选 `download_deadline` 只对像素守卫传值,非像素路径继续传 `None` 保持既有语义不变。结构断言固定守卫内必须同时出现 `request_deadline.unwrap_or_else(` 与 `EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION`,防止兜底上界被移除后静默退回无界。 --- diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index e694ce906..993dbe364 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -3263,8 +3263,16 @@ async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( reason: String, ) -> (DownloadedOpenAiImage, Option, Option) { // 中文注释:只有尚未成功取得 provider 原图的降级分支才会走到这里。像素预算此时 - // 多半已经耗尽,不能拿它去绑这次回读(否则必然失败、守卫形同虚设),改用外层 - // 请求 deadline 作为绝对上界,保证这唯一一次 GET 有界。 + // 多半已经耗尽,不能拿它去绑这次回读(否则必然失败、守卫形同虚设)。优先用外层 + // 请求 deadline,但它只在队列 worker 路径上有值——inline HTTP 请求的 + // RequestContext 默认没有 external_call_deadline,此时必须自行重新计时派生一个 + // 上界,绝不允许出现无界 GET。 + let repair_deadline = request_deadline.unwrap_or_else(|| { + let started_at = Instant::now(); + started_at + .checked_add(EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION) + .unwrap_or(started_at) + }); let (image, dimension_error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, @@ -3272,7 +3280,7 @@ async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( delivery_width, delivery_height, postprocessed, - request_deadline, + Some(repair_deadline), ) .await; (image, dimension_error, Some(reason)) @@ -9423,12 +9431,19 @@ mod tests { "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", &["download_editor_persisted_image_object"], ); - // 会回读原图的守卫必须带绝对 deadline 参数,不允许无界 GET。 + // 会回读原图的守卫必须带绝对 deadline,且在外层 request_deadline 缺失时 + // (inline HTTP 请求的 RequestContext 默认就没有)必须自行派生兜底上界, + // 不允许退化成无界 GET。 assert_function_contains_in_order( source, "async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard", "fn degrade_editor_pixel_art_with_provider_source", - &["request_deadline: Option", "request_deadline,"], + &[ + "request_deadline: Option", + "request_deadline.unwrap_or_else(", + "EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION", + "Some(repair_deadline),", + ], ); // 守卫必须复用非像素路径的同一套尺寸比对与 alpha 回贴,保证尺寸一致时 // 不产生额外 OSS GET,漂移修不好时返回尺寸错误。 From 5ec7182d65b01fdf3f7f2098276c28f372e598da Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 13:23:07 +0000 Subject: [PATCH 17/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=9B=BE=E5=B1=82=E6=9F=A5=E8=AF=A2=E5=9C=A8=E9=87=8D=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E7=AA=97=E5=8F=A3=E4=B8=8B=E7=9A=84=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit only exposes character animation generation 用例先 await findByAltText 取到 第一个图层,再用同步 getByAltText 取第二个。合并 web master 带入的项目封面 快照改动会在图层落位后触发一次重渲染,同步查询正好落进该窗口就取不到元素, CI 962 因此失败。 该写法在 master 上逐字相同,属既有竞态,非本分支引入;但它挡住本 PR 的 CI, 故改为异步查询容忍这次重渲染,不改变断言语义与产品代码。 Co-Authored-By: Claude Opus 5 --- .../ImageCanvasEditorGenerationIntegration.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 551005038..c307a36ee 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3248,7 +3248,9 @@ describe('ImageCanvasEditorView generation integration', () => { }); expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull(); - const characterLayer = screen.getByAltText('画布图片:市场老妇人'); + // 项目封面快照会在图层落位后触发一次重渲染,同步查询可能正好落进该窗口; + // 这里用异步查询容忍这次重渲染,不改变断言语义。 + const characterLayer = await screen.findByAltText('画布图片:市场老妇人'); fireEvent.click(characterLayer.closest('button')!); expect(screen.getByText('角色')).toBeTruthy(); expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();