新增图片生成像素艺术后处理

新增确定性像素网格规整模块并保留第三方 MIT 许可证。

为普通图片、角色和图标图集接入像素艺术选项、并发预算和失败降级。

同步前端交互、请求契约、OpenAPI、项目文档与测试。
This commit is contained in:
2026-07-29 03:46:03 +00:00
parent 271d5d7bd3
commit 732fcf0b45
25 changed files with 2343 additions and 72 deletions
@@ -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": {
@@ -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`
@@ -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=<screenColor>``seg_model=<segModel>`。父流程不直连 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`:按图标规范图和素材描述数组生成 spritesheetapi-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` 和自动决策纯色背景素材提取提示词生成素材 spritesheetapi-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 不得把安全移动误判为素材替换。生成结果必须在加入画布前写入生成历史,自动适合视图不得覆盖这条栈顶记录。
@@ -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 URLmultipart 固定传 `image_url``screen_color=<screenColor>``seg_model=<segModel>``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 原图右侧追加自动识别的独立素材,并同步写入素材库。
@@ -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"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。
+77
View File
@@ -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 {
@@ -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()),
File diff suppressed because it is too large Load Diff
@@ -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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<EditorImageGenerationRequest>,
payload: Result<Json<EditorImageGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
payload: Result<Json<EditorIconSpritesheetGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, 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")
@@ -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.
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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();
@@ -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',
@@ -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
@@ -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;
@@ -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,
@@ -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: [],
@@ -47,6 +47,7 @@ function ImageOptionsHarness({
<output aria-label="当前模型">{dialog.imageModel}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
<output aria-label="当前尺寸">{dialog.imageSize}</output>
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前占位">
@@ -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(
<ImageOptionsHarness
initialDialog={{
mode,
prompt: '',
status: 'failed',
errorMessage: '旧错误',
style: 'none',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
}}
/>,
);
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(
<ImageOptionsHarness
initialDialog={{
mode,
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
}}
/>,
);
expect(
screen.queryByRole('checkbox', { name: '像素艺术' }),
).toBeNull();
},
);
it('updates dimensions from a menu, keeps the menu open and marks the selection', () => {
render(
<ImageOptionsHarness
@@ -137,6 +137,10 @@ export function ImageCanvasGenerationImageOptionsView({
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(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}
</div>
) : null}
{supportsImageStyle ? (
<label className="image-canvas-editor__image-style-toggle">
<input
type="checkbox"
checked={dialog.style === 'pixelArt'}
disabled={isGenerating}
onChange={(event) =>
updateDialog({
style: event.target.checked ? 'pixelArt' : 'none',
})
}
/>
<span></span>
</label>
) : null}
{includeModel ? (
<div
className={[
@@ -47,6 +47,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
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',
},
@@ -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',
},
+32
View File
@@ -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%;
}
@@ -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',
@@ -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 } : {}),