From 732fcf0b457739e742078ffd5b4e4bf2d5437be7 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 29 Jul 2026 03:46:03 +0000 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=BE=E7=89=87=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=83=8F=E7=B4=A0=E8=89=BA=E6=9C=AF=E5=90=8E=E5=A4=84?= =?UTF-8?q?=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 } : {}),