From bc76b1327db5083a11cc1a980dfa0562088086a9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 18 Jul 2026 17:44:43 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E5=BC=B9=E7=AA=97=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目重命名弹窗改用平台工具弹窗壳继承主题 新增暗色主题下的重命名弹窗回归测试 补充 portal 弹窗主题丢失的排障记录 --- docs/project-memory/shared-memory/pitfalls.md | 8 +++++++ .../project/ProjectGalleryView.test.tsx | 23 +++++++++++++++++++ src/components/project/ProjectGalleryView.tsx | 6 ++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d1b8299ea..1b006a9a3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3181,3 +3181,11 @@ - 处理:在调用 `try_with_tx` 前通过 `let caller = ctx.sender()` 捕获真实调用者,再把 `caller` 显式传入事务函数;迁移、后台账号、runtime profile、外部生成等现有 procedure 已采用这一模式。`npm run check:spacetime-runtime-access` 禁止编辑器 runtime writer 鉴权重新直接读取事务 `ctx.sender()`。运行环境升级到 2.6.1 后继续保留显式 caller,避免鉴权依赖 SDK 版本细节。 - 验证:运行 `npm run check:spacetime-runtime-access`、`cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`;发布使用 SpacetimeDB 2.6.1 构建的新模块后,以 runtime writer identity 重试 `POST /api/editor/icon-spritesheets/slices`,确认来源查询、分片素材写入与 cohort 完成不再返回 identity 403。 - 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`scripts/check-spacetime-runtime-access.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## Portal 弹窗必须在遮罩根节点携带平台主题 + +- 现象:项目库点击“重命名”后,标题、输入框和按钮仍显示,但弹窗面板及遮罩背景变透明,看起来像“改名界面的背景没了”。 +- 原因:`UnifiedModal` 默认 portal 到 `document.body`;若业务入口只在页面内层继承 `platform-theme`,portal 根节点不会继承该容器的 CSS 变量。此时 `.platform-modal-shell` 的 `background: var(--platform-modal-fill)` 和 `.platform-overlay` 的背景声明都会失效。 +- 处理:平台白底工具弹窗优先复用 `PlatformToolModalShell`,由共享壳读取当前 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--` 挂到 portal overlay;不要用硬编码白底掩盖主题变量缺失。必须直接使用 `UnifiedModal` 的特殊场景,也要在 `overlayClassName` 显式传递当前平台主题。 +- 验证:在 light / dark 主题下打开 portal 弹窗,断言 dialog 的 overlay 携带对应主题类,并在真实浏览器核对 panel 与遮罩的 computed background 均非透明。 +- 关联:`src/components/project/ProjectGalleryView.tsx`、`src/components/common/PlatformToolModalShell.tsx`、`src/components/common/UnifiedModal.tsx`。 diff --git a/src/components/project/ProjectGalleryView.test.tsx b/src/components/project/ProjectGalleryView.test.tsx index a118a2c76..aea2521e7 100644 --- a/src/components/project/ProjectGalleryView.test.tsx +++ b/src/components/project/ProjectGalleryView.test.tsx @@ -387,6 +387,29 @@ describe('ProjectGalleryView', () => { }); }); + it('keeps the project rename modal inside the active platform theme', async () => { + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + const user = userEvent.setup(); + + render( + + + , + ); + + await screen.findByText('角色设定板'); + await user.click( + screen.getByRole('button', { name: '打开项目角色设定板菜单' }), + ); + await user.click(screen.getByRole('menuitem', { name: /重命名/u })); + + const dialog = screen.getByRole('dialog', { name: '重命名' }); + expect(dialog.closest('.platform-theme--dark')).toBeTruthy(); + expect(dialog.className).toContain('platform-remap-surface'); + }); + it('supports batch selection actions from the bottom toolbar', async () => { listEditorProjectsMock.mockResolvedValueOnce(projectItems); deleteEditorProjectMock.mockResolvedValue('deleted'); diff --git a/src/components/project/ProjectGalleryView.tsx b/src/components/project/ProjectGalleryView.tsx index 4d8286020..a6629c366 100644 --- a/src/components/project/ProjectGalleryView.tsx +++ b/src/components/project/ProjectGalleryView.tsx @@ -30,7 +30,7 @@ import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformMediaFrame } from '../common/PlatformMediaFrame'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; -import { UnifiedModal } from '../common/UnifiedModal'; +import { PlatformToolModalShell } from '../common/PlatformToolModalShell'; import { LOCAL_PROJECT_COVER_CACHE_ASSET_KIND, resolveProjectCoverResource, @@ -436,7 +436,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
{projectCards}
)} - ) : null} - + {isSelectionMode ? ( From 6926e5ecb14db75f2c0c5ff70bce91d732e51c0b Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 18 Jul 2026 18:58:56 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8E=9F=E5=9B=BE=E5=9B=9E=E5=A1=AB=E7=94=BB?= =?UTF-8?q?=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 角色生成成功时同时回填透明主图和右侧原图 保持透明图为生成器主锚点并保留失败降级边界 维持图标和UI生成不回填中间原图的现有规则 同步图片画布多产物文档和项目共享记忆 --- .../shared-memory/decision-log.md | 12 +-- docs/project-memory/shared-memory/pitfalls.md | 10 +-- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...端架构】外部生成Worker化方案-2026-06-03.md | 2 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 4 +- .../crates/api-server/src/editor_project.rs | 76 +++++++++++++++++-- 6 files changed, 85 insertions(+), 21 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d7e4092ba..86a0aa133 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -39,12 +39,12 @@ - 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、`npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`。 -## 2026-07-18 图片多产物任务的中间原图不进入正常完成画布 +## 2026-07-18 图片多产物任务的正常完成画布按类型区分 -- 背景:角色形象、图标 spritesheet 和 UI 素材提取会同时持久化带纯色背景的 provider 原图与透明后处理结果。正常成功时若把两者都写入 `canvasCompletion`,用户执行一次任务会在画布看到两张近似图片,误以为重复生成;任务与扣费实际仍只有一次。 -- 决策:provider 原图继续写入 OSS、`asset_object`、项目资源和账号素材库,保留追溯与后处理失败恢复能力,但正常成功的画布只放透明主结果;图标和 UI 在主图集后仍可放业务上明确需要的拆分素材。只有透明背景处理最终失败时,才把 provider 原图作为唯一主图完成占位并返回 warning。 +- 背景:角色形象、图标 spritesheet 和 UI 素材提取会同时持久化带纯色背景的 provider 原图与透明后处理结果。角色形象需要同时查看透明成品与 provider 原图;图标和 UI 若把原图也写入 `canvasCompletion`,则会与透明主图集及拆分素材重复占据画布。任务与扣费实际仍只有一次。 +- 决策:provider 原图继续写入 OSS、`asset_object`、项目资源和账号素材库,保留追溯与后处理失败恢复能力。角色形象正常成功时,透明结果作为主图并保持生成器 `generatedLayerId` 锚点,provider 原图作为第二个图层放在透明主结果右侧;图标 spritesheet 和 UI 素材提取正常成功时仍不把 provider 原图放入画布,只放透明主图集及业务上明确需要的拆分素材。只有透明背景处理最终失败时,三类任务才都把 provider 原图作为唯一主图完成占位并返回 warning。 - 影响范围:角色形象、图标 spritesheet、UI 素材提取的画布完成快照,以及多产物持久化与画布展示边界。 -- 验证方式:后端定向测试断言角色正常成功只落一个透明角色图层、图标 / UI 不把 provider 原图加入画布、无切片时只落透明主图集,同时保留 source-only 失败降级测试;并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 验证方式:后端定向测试断言角色正常成功同时落透明角色主图与右侧 provider 原图、`generatedLayerId` 仍指向透明主图,图标 / UI 不把 provider 原图加入画布、无切片时只落透明主图集,同时保留 source-only 失败降级测试;并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 ## 2026-07-17 图片改造保持源图与所选清晰度 @@ -135,7 +135,7 @@ - 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。 - 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时保留纯色背景原图与透明后处理结果;透明背景处理最终失败时只保留已经持久化的 provider 原图,并按下一条降级规则收口。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。 -- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果,provider 原图只保留在项目资源和账号素材库,不作为第二张近似图片进入画布;图标和 UI 的业务拆分素材仍可随透明主图集进入画布。角色形象、图标 spritesheet 或 UI 素材提取已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 +- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果。角色形象同时把 provider 原图作为第二个图层放在透明主结果右侧;图标和 UI 的 provider 原图只保留在项目资源和账号素材库,不进入正常完成画布,其业务拆分素材仍可随透明主图集进入画布。角色形象、图标 spritesheet 或 UI 素材提取已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 - 2026-07-16 告警契约补充:inline / external v1 继续返回结构化原始诊断;queue 有意把通用 `warning` 或 `sliceWarning` 归一为展示就绪字符串,通用 `warning.reason` 原样保留,`sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀,摘要与 BFF 原样投影,Web 直接展示。历史值保留写入时快照,不按新格式回填或推断;该内部字符串契约通过 API/worker 与 Web 同一维护窗口、同版本发布收口,不增加混部兼容层。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`character_animation_assets.rs`、外部生成任务摘要、图片画布完成快照、账号素材库和前端生成提示。 - 验证方式:覆盖中间产物登记先于后处理、默认素材文件夹、图集拆分降级、inline / queue warning 和主结果锚定的定向测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、前端定向测试、`npm run check:encoding`、`git diff --check`。 @@ -460,7 +460,7 @@ ## 2026-06-18 图片画布 UI 设计图提取素材保留图集 - 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。 -- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 +- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;2026-07-18 起,正常完成画布不放 provider 原图,只放透明 spritesheet 与成功拆分的图标。拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 - 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。 - 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1b006a9a3..0072555e1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -22,12 +22,12 @@ - 验证:覆盖 nanobanana2 / gpt-image-2 的比例与 K 档尺寸矩阵、VectorEngine 最终请求体、普通图片 / 角色 / 图标 / UI 占位,以及低分辨率去背景结果只贡献 alpha、不贡献被放大的 RGB。 - 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/vector_engine/request.rs`。 -## 多产物任务保留中间原图不等于把中间原图放进画布 +## 多产物任务的中间原图是否入画布必须按类型区分 -- 现象:角色形象只提交一次、任务列表和泥点也只记录一次,但画布同时出现透明角色与纯色背景角色;图标或 UI 生成也可能多出一张纯色背景图集。 -- 原因:后端为了可追溯和后处理失败恢复而持久化 provider 原图时,又把它与透明主结果一起传入同一次 `canvasCompletion`,把“资源必须保留”错误等同为“画布必须展示”。 -- 处理:provider 原图继续写 OSS、项目资源和账号素材库;正常成功的画布只放透明主结果及明确需要的图标 / UI 拆分素材。仅当透明处理最终失败时,才用 provider 原图作为唯一主图完成占位。 -- 验证:覆盖角色正常成功只落一个图层、图标 / UI 正常成功不落 provider 原图、无切片时只有透明主图集,以及透明处理失败仍由原图单独完成占位。 +- 现象:角色形象正常成功后缺少可对照的纯色背景原图,或者图标 / UI 生成后画布多出一张与透明主图集近似的纯色背景图集。 +- 原因:角色形象与图标 / UI 的画布交付需求不同,若把三类任务统一成“原图都入画布”或“原图都不入画布”,就会分别造成图集重复或角色原图缺失。 +- 处理:provider 原图始终写入 OSS、项目资源和账号素材库。角色形象正常成功时同时落透明主结果与右侧 provider 原图,生成器 `generatedLayerId` 仍指向透明主结果;图标 spritesheet 和 UI 素材提取正常成功时不把 provider 原图放入画布,只放透明主图集及明确需要的拆分素材。仅当透明处理最终失败时,才用 provider 原图作为唯一主图完成占位。 +- 验证:覆盖角色正常成功落两个图层且原图位于透明图右侧、`generatedLayerId` 指向透明图,图标 / UI 正常成功不落 provider 原图、无切片时只有透明主图集,以及透明处理失败仍由原图单独完成占位。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。 ## phase 上报的业务拒绝与传输失败不能共用字符串错误 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index c9b43b5ab..c528cb6a7 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -24,7 +24,7 @@ - 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 -- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,provider 原图只保留在项目资源与账号素材库,不作为第二张近似图片进入画布;图标和 UI 的实际拆分素材从透明主图集右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 +- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS。透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点;角色形象同时把 provider 原图作为第二个图层放在透明主结果右侧,图标 spritesheet 和 UI 素材提取的 provider 原图只保留在项目资源与账号素材库、不进入画布,图标和 UI 的实际拆分素材从透明主图集右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 1316af685..2593acf7e 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -197,7 +197,7 @@ controller 配置: 角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。 -透明背景处理正常成功时,provider 原图仍保留在项目资源与账号素材库中,但不作为第二张近似图片进入画布;角色画布只放透明主结果,图标和 UI 画布放透明主图集以及实际拆分出的业务素材。这样一次任务仍能保留全部可恢复产物,同时不会让用户误以为请求执行或扣费了两次。 +透明背景处理正常成功时,角色画布同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧。图标 spritesheet 和 UI 素材提取的 provider 原图仍只保留在项目资源与账号素材库中,不进入正常完成画布;这两类画布只放透明主图集以及实际拆分出的业务素材。 inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:通用 `warning` 优先并原样保留完整 `reason`;只有不存在通用 `warning` 时,才给 `sliceWarning.reason` 添加“图集已生成,但自动拆分未完成:”前缀。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 4489dce74..c01911268 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -65,7 +65,7 @@ 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用共享 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。首次请求失败后立即重试 `1` 次,第二次仍失败进入“阿里云通用抠图 → 本地键色”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回透明 PNG Data URL 及 `objectKey` / `assetObjectId`。三段透明背景处理最终仍失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。 +- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用共享 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。首次请求失败后立即重试 `1` 次,第二次仍失败进入“阿里云通用抠图 → 本地键色”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回透明 PNG Data URL 及 `objectKey` / `assetObjectId`,画布同时写入透明主结果和 provider 原图,生成器 `generatedLayerId` 锚定透明主结果,provider 原图作为第二个图层放在其右侧。三段透明背景处理最终仍失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。 - 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;透明背景正常成功与最终失败保留 provider 原图的收口规则和角色新生成一致。普通图片图层重绘仍保持 `kind: "quick-edit"`。 ## 生成规范参考图 @@ -114,7 +114,7 @@ - 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。 - 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。 - 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。 -- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,最终失败时则保留并返回已经持久化的 provider 原图和通用 warning。若 BgFilter 返回较小图片,只允许把其 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不得放大低分辨率透明成品。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 +- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,并把透明主结果与其右侧 provider 原图一起写入画布,`generatedLayerId` 仍锚定透明主结果;最终失败时则只保留并返回已经持久化的 provider 原图和通用 warning。若 BgFilter 返回较小图片,只允许把其 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不得放大低分辨率透明成品。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 - `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 - 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 - 本次验证命令: diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 535c4d513..2e150d694 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1952,7 +1952,13 @@ pub(crate) async fn generate_editor_image_for_owner( .await?; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { - build_primary_canvas_layer_items(completion, generated_asset.resource.as_ref()) + build_multi_asset_canvas_layer_items( + completion, + generated_asset.resource.as_ref(), + source_record + .as_ref() + .and_then(|record| record.resource.as_ref()), + ) } else { (Vec::new(), None) }; @@ -6548,6 +6554,52 @@ fn build_primary_canvas_layer_items( (vec![primary_item], Some(primary_layer_id)) } +fn build_multi_asset_canvas_layer_items( + completion: &EditorCanvasGenerationCompletionRequest, + primary_resource: Option<&EditorProjectResourcePayload>, + source_resource: Option<&EditorProjectResourcePayload>, +) -> (Vec, Option) { + let (mut items, primary_layer_id) = + build_primary_canvas_layer_items(completion, primary_resource); + let Some(primary_item) = items.first() else { + return (items, primary_layer_id); + }; + let primary_right = primary_item + .get("x") + .and_then(Value::as_f64) + .unwrap_or(completion.placeholder.x) + + primary_item + .get("width") + .and_then(Value::as_f64) + .unwrap_or(completion.placeholder.width.max(1.0)); + let primary_y = primary_item + .get("y") + .and_then(Value::as_f64) + .unwrap_or(completion.placeholder.y); + + if let Some(source_resource) = source_resource { + let source_layer_id = generated_canvas_layer_id(source_resource.resource_id.as_str()); + let mut source_item = build_generated_canvas_layer_item( + completion, + &completion.placeholder, + source_resource, + source_layer_id.as_str(), + 0, + ); + if let Some(object) = source_item.as_object_mut() { + object.insert("x".to_string(), json!(primary_right + 32.0)); + object.insert("y".to_string(), json!(primary_y)); + object.insert( + "title".to_string(), + json!(format!("{}(原图)", completion.title.trim())), + ); + } + items.push(source_item); + } + + (items, primary_layer_id) +} + fn build_icon_spritesheet_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, spritesheet_resource: Option<&EditorProjectResourcePayload>, @@ -9306,7 +9358,7 @@ mod tests { } #[test] - fn editor_canvas_character_generation_only_places_processed_result() { + fn editor_canvas_character_generation_places_processed_result_and_provider_source() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-character".to_string()), title: "角色形象 1".to_string(), @@ -9325,10 +9377,16 @@ mod tests { 400, 600, ); + let source = editor_project_resource_for_canvas_test( + "resource-character-source", + "character", + 400, + 600, + ); let (items, primary_layer_id) = - build_primary_canvas_layer_items(&completion, Some(&processed)); + build_multi_asset_canvas_layer_items(&completion, Some(&processed), Some(&source)); - assert_eq!(items.len(), 1); + assert_eq!(items.len(), 2); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-character-cutout") @@ -9336,6 +9394,11 @@ mod tests { assert_eq!(items[0]["resourceId"], json!("resource-character-cutout")); assert_eq!(items[0]["x"], json!(0.0)); assert_eq!(items[0]["y"], json!(-70.0)); + assert_eq!(items[1]["resourceId"], json!("resource-character-source")); + assert_eq!(items[1]["title"], json!("角色形象 1(原图)")); + assert_eq!(items[1]["assetKind"], json!("character")); + assert_eq!(items[1]["x"], json!(432.0)); + assert_eq!(items[1]["y"], json!(-70.0)); let layers = json!([{ "itemType": "generation-dialog", @@ -9366,7 +9429,7 @@ mod tests { .and_then(Value::as_str) .is_some_and(|id| id.starts_with("resource-character-"))) .count(), - 1 + 2 ); let dialog = completed_items .iter() @@ -10514,6 +10577,7 @@ mod tests { "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", "persist_editor_provider_source_resource", + "build_multi_asset_canvas_layer_items", "if is_character_generation", ], ); @@ -10957,7 +11021,7 @@ mod tests { "persist_editor_generated_image(", "persist_editor_generated_asset(", "slice_generated_icon_spritesheet", - "build_primary_canvas_layer_items", + "build_multi_asset_canvas_layer_items", ] { assert!( !fallback.contains(forbidden), From 4ceb91cd48469d6a7139996a7c18f34cfff236e0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 18 Jul 2026 19:31:44 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=A4=9A=E4=BA=A7=E7=89=A9=E5=8E=9F=E5=9B=BE=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E7=94=BB=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图标素材成功时同时回填透明图集和provider原图 UI素材提取成功时保留原图并将切片排列到其右侧 保持透明主结果锚点和后处理失败原图降级 同步多产物画布决策、踩坑与技术方案 --- .../shared-memory/decision-log.md | 12 ++--- docs/project-memory/shared-memory/pitfalls.md | 10 ++--- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...端架构】外部生成Worker化方案-2026-06-03.md | 2 +- .../crates/api-server/src/editor_project.rs | 44 ++++++++++++++----- 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 86a0aa133..94e45925d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -39,12 +39,12 @@ - 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、`npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`。 -## 2026-07-18 图片多产物任务的正常完成画布按类型区分 +## 2026-07-18 图片多产物任务的原图进入正常完成画布 -- 背景:角色形象、图标 spritesheet 和 UI 素材提取会同时持久化带纯色背景的 provider 原图与透明后处理结果。角色形象需要同时查看透明成品与 provider 原图;图标和 UI 若把原图也写入 `canvasCompletion`,则会与透明主图集及拆分素材重复占据画布。任务与扣费实际仍只有一次。 -- 决策:provider 原图继续写入 OSS、`asset_object`、项目资源和账号素材库,保留追溯与后处理失败恢复能力。角色形象正常成功时,透明结果作为主图并保持生成器 `generatedLayerId` 锚点,provider 原图作为第二个图层放在透明主结果右侧;图标 spritesheet 和 UI 素材提取正常成功时仍不把 provider 原图放入画布,只放透明主图集及业务上明确需要的拆分素材。只有透明背景处理最终失败时,三类任务才都把 provider 原图作为唯一主图完成占位并返回 warning。 +- 背景:角色形象、图标 spritesheet 和 UI 素材提取会同时持久化带纯色背景的 provider 原图与透明后处理结果;这些原图都需要在画布中可直接查看和复用。任务与扣费实际仍只有一次。 +- 决策:provider 原图继续写入 OSS、`asset_object`、项目资源和账号素材库。三类任务透明处理正常成功时,透明结果作为主图并保持生成器 `generatedLayerId` 锚点,provider 原图作为第二个图层放在透明主结果右侧;图标和 UI 的业务拆分素材从原图右侧继续排列。只有透明背景处理最终失败时,才把 provider 原图作为唯一主图完成占位并返回 warning。 - 影响范围:角色形象、图标 spritesheet、UI 素材提取的画布完成快照,以及多产物持久化与画布展示边界。 -- 验证方式:后端定向测试断言角色正常成功同时落透明角色主图与右侧 provider 原图、`generatedLayerId` 仍指向透明主图,图标 / UI 不把 provider 原图加入画布、无切片时只落透明主图集,同时保留 source-only 失败降级测试;并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 验证方式:后端定向测试断言三类任务正常成功都落透明主图与右侧 provider 原图、`generatedLayerId` 仍指向透明主图,图标 / UI 拆分素材继续排列在原图右侧,同时保留 source-only 失败降级测试;并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 ## 2026-07-17 图片改造保持源图与所选清晰度 @@ -135,7 +135,7 @@ - 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。 - 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时保留纯色背景原图与透明后处理结果;透明背景处理最终失败时只保留已经持久化的 provider 原图,并按下一条降级规则收口。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。 -- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果。角色形象同时把 provider 原图作为第二个图层放在透明主结果右侧;图标和 UI 的 provider 原图只保留在项目资源和账号素材库,不进入正常完成画布,其业务拆分素材仍可随透明主图集进入画布。角色形象、图标 spritesheet 或 UI 素材提取已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 +- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果,角色形象、图标 spritesheet 和 UI 素材提取都把 provider 原图作为第二个图层放在透明主结果右侧;图标和 UI 的业务拆分素材从原图右侧继续排列。三类任务已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 - 2026-07-16 告警契约补充:inline / external v1 继续返回结构化原始诊断;queue 有意把通用 `warning` 或 `sliceWarning` 归一为展示就绪字符串,通用 `warning.reason` 原样保留,`sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀,摘要与 BFF 原样投影,Web 直接展示。历史值保留写入时快照,不按新格式回填或推断;该内部字符串契约通过 API/worker 与 Web 同一维护窗口、同版本发布收口,不增加混部兼容层。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`character_animation_assets.rs`、外部生成任务摘要、图片画布完成快照、账号素材库和前端生成提示。 - 验证方式:覆盖中间产物登记先于后处理、默认素材文件夹、图集拆分降级、inline / queue warning 和主结果锚定的定向测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、前端定向测试、`npm run check:encoding`、`git diff --check`。 @@ -460,7 +460,7 @@ ## 2026-06-18 图片画布 UI 设计图提取素材保留图集 - 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。 -- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;2026-07-18 起,正常完成画布不放 provider 原图,只放透明 spritesheet 与成功拆分的图标。拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 +- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放 provider 原图和拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库和画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分素材从 provider 原图右侧继续排列。拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 - 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。 - 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0072555e1..6362bfae8 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -22,12 +22,12 @@ - 验证:覆盖 nanobanana2 / gpt-image-2 的比例与 K 档尺寸矩阵、VectorEngine 最终请求体、普通图片 / 角色 / 图标 / UI 占位,以及低分辨率去背景结果只贡献 alpha、不贡献被放大的 RGB。 - 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/vector_engine/request.rs`。 -## 多产物任务的中间原图是否入画布必须按类型区分 +## 多产物任务的中间原图不能只落资源库 -- 现象:角色形象正常成功后缺少可对照的纯色背景原图,或者图标 / UI 生成后画布多出一张与透明主图集近似的纯色背景图集。 -- 原因:角色形象与图标 / UI 的画布交付需求不同,若把三类任务统一成“原图都入画布”或“原图都不入画布”,就会分别造成图集重复或角色原图缺失。 -- 处理:provider 原图始终写入 OSS、项目资源和账号素材库。角色形象正常成功时同时落透明主结果与右侧 provider 原图,生成器 `generatedLayerId` 仍指向透明主结果;图标 spritesheet 和 UI 素材提取正常成功时不把 provider 原图放入画布,只放透明主图集及明确需要的拆分素材。仅当透明处理最终失败时,才用 provider 原图作为唯一主图完成占位。 -- 验证:覆盖角色正常成功落两个图层且原图位于透明图右侧、`generatedLayerId` 指向透明图,图标 / UI 正常成功不落 provider 原图、无切片时只有透明主图集,以及透明处理失败仍由原图单独完成占位。 +- 现象:角色形象、图标 spritesheet 或 UI 素材提取正常成功后,画布只有透明结果,用户无法直接对照和复用 provider 原图。 +- 原因:后端虽然为追溯和后处理失败恢复持久化了 provider 原图,但完成画布只写入透明主结果和拆分素材,把“资源已保存”错误等同为“用户已拿到原图”。 +- 处理:provider 原图始终写入 OSS、项目资源和账号素材库;三类任务正常成功时都同时落透明主结果与右侧 provider 原图,生成器 `generatedLayerId` 仍指向透明主结果,图标 / UI 拆分素材从原图右侧继续排列。仅当透明处理最终失败时,才用 provider 原图作为唯一主图完成占位。 +- 验证:覆盖三类任务正常成功都落透明结果与原图、原图位于透明图右侧、`generatedLayerId` 指向透明图、图标 / UI 拆分素材位于原图右侧,以及透明处理失败仍由原图单独完成占位。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。 ## phase 上报的业务拒绝与传输失败不能共用字符串错误 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index c528cb6a7..045ca04b2 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -24,7 +24,7 @@ - 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 -- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS。透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点;角色形象同时把 provider 原图作为第二个图层放在透明主结果右侧,图标 spritesheet 和 UI 素材提取的 provider 原图只保留在项目资源与账号素材库、不进入画布,图标和 UI 的实际拆分素材从透明主图集右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 +- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS。透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,三类任务同时把 provider 原图作为第二个图层放在透明主结果右侧,图标和 UI 的实际拆分素材从 provider 原图右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 2593acf7e..d0cf8c26f 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -197,7 +197,7 @@ controller 配置: 角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。 -透明背景处理正常成功时,角色画布同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧。图标 spritesheet 和 UI 素材提取的 provider 原图仍只保留在项目资源与账号素材库中,不进入正常完成画布;这两类画布只放透明主图集以及实际拆分出的业务素材。 +透明背景处理正常成功时,角色形象、图标 spritesheet 和 UI 素材提取的画布都同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧;图标和 UI 实际拆分出的业务素材从 provider 原图右侧继续排列。 inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:通用 `warning` 优先并原样保留完整 `reason`;只有不存在通用 `warning` 时,才给 `sliceWarning.reason` 添加“图集已生成,但自动拆分未完成:”前缀。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 2e150d694..5b4436f55 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4485,6 +4485,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), + source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { @@ -5223,6 +5224,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), + source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { @@ -6603,11 +6605,12 @@ fn build_multi_asset_canvas_layer_items( fn build_icon_spritesheet_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, spritesheet_resource: Option<&EditorProjectResourcePayload>, + source_resource: Option<&EditorProjectResourcePayload>, icons: &[EditorIconSpritesheetIconResponse], ) -> (Vec, Option) { let placeholder = completion.placeholder.clone(); let (mut items, primary_layer_id) = - build_primary_canvas_layer_items(completion, spritesheet_resource); + build_multi_asset_canvas_layer_items(completion, spritesheet_resource, source_resource); if items.is_empty() { return (items, primary_layer_id); } @@ -9514,7 +9517,7 @@ mod tests { } #[test] - fn editor_canvas_spritesheet_items_include_processed_sheet_and_slices() { + fn editor_canvas_spritesheet_items_include_processed_sheet_provider_source_and_slices() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-ui-assets".to_string()), title: "UI素材图集".to_string(), @@ -9533,6 +9536,12 @@ mod tests { 512, 512, ); + let source = editor_project_resource_for_canvas_test( + "resource-ui-sheet-source", + "icon-spritesheet", + 512, + 512, + ); let slice = editor_project_resource_for_canvas_test("resource-ui-slice", "icon", 128, 128); let icons = vec![EditorIconSpritesheetIconResponse { name: "开始按钮".to_string(), @@ -9546,22 +9555,25 @@ mod tests { let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( &completion, Some(&processed), + Some(&source), icons.as_slice(), ); - assert_eq!(items.len(), 2); + assert_eq!(items.len(), 3); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-ui-sheet") ); assert_eq!(items[0]["resourceId"], json!("resource-ui-sheet")); - assert_eq!(items[1]["resourceId"], json!("resource-ui-slice")); - let sheet_right = items[0]["x"].as_f64().unwrap() + items[0]["width"].as_f64().unwrap(); - assert!(items[1]["x"].as_f64().unwrap() > sheet_right); + assert_eq!(items[1]["resourceId"], json!("resource-ui-sheet-source")); + assert_eq!(items[1]["title"], json!("UI素材图集(原图)")); + assert_eq!(items[2]["resourceId"], json!("resource-ui-slice")); + let source_right = items[1]["x"].as_f64().unwrap() + items[1]["width"].as_f64().unwrap(); + assert!(items[2]["x"].as_f64().unwrap() > source_right); } #[test] - fn editor_canvas_spritesheet_items_keep_only_processed_sheet_when_slices_are_empty() { + fn editor_canvas_spritesheet_items_keep_processed_sheet_and_provider_source_without_slices() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-icon-sheet".to_string()), title: "图标素材图集".to_string(), @@ -9580,11 +9592,23 @@ mod tests { 512, 512, ); - let (items, primary_layer_id) = - build_icon_spritesheet_canvas_layer_items(&completion, Some(&processed), &[]); + let source = editor_project_resource_for_canvas_test( + "resource-icon-sheet-source", + "icon-spritesheet", + 512, + 512, + ); + let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( + &completion, + Some(&processed), + Some(&source), + &[], + ); - assert_eq!(items.len(), 1); + assert_eq!(items.len(), 2); assert_eq!(items[0]["resourceId"], json!("resource-icon-sheet")); + assert_eq!(items[1]["resourceId"], json!("resource-icon-sheet-source")); + assert_eq!(items[1]["title"], json!("图标素材图集(原图)")); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-icon-sheet") From d465e9b66cbbdef1af545f412300f3f06ef64fa2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 18 Jul 2026 19:52:35 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=9B=BE=E9=9B=86?= =?UTF-8?q?=E6=8B=86=E5=88=86=E4=B8=AD=E7=9A=84=E5=89=8D=E7=AB=AF=E8=A1=A8?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 维护逐图层拆分状态并在请求结束后可靠清理 源图集显示扫描蒙层和拆图中状态胶囊 工具栏显示旋转图标并禁用重复提交 补充工作流、画布、工具栏测试及入口验收文档 --- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 2 +- .../image-editor/ImageCanvasEditorView.tsx | 2 + ...ageCanvasSelectedLayerToolbarView.test.tsx | 30 +++++++++ .../ImageCanvasSelectedLayerToolbarView.tsx | 19 ++++-- .../image-editor/ImageCanvasStageView.tsx | 9 +++ .../ImageCanvasWorldView.test.tsx | 16 +++++ .../image-editor/ImageCanvasWorldView.tsx | 15 +++-- .../useImageCanvasGenerationWorkflow.test.tsx | 64 +++++++++++++++++++ .../useImageCanvasGenerationWorkflow.ts | 17 +++++ 9 files changed, 165 insertions(+), 9 deletions(-) diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 7e64fed07..041607f94 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -80,5 +80,5 @@ - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,上传参考图优先提交 `objectKey`,并写入 `generationInputs.references`。 - 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层;透明图集成功但拆分失败时只出现透明图集,透明背景处理最终失败时只出现 provider 原图。 -- 选中图集图层时显示 `拆分图集`,点击后不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。 +- 选中图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。 - 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 367cae2e7..cc46041ba 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1355,6 +1355,7 @@ export function ImageCanvasEditorView({ openCropExpandPanel, removeSelectedLayerBackground, splitSelectedIconSpritesheet, + splittingIconSpritesheetLayerIds, extractUiDesignAssets, pickCharacterSpecFromLayer, pickGenerationReferenceFromLayer, @@ -2119,6 +2120,7 @@ export function ImageCanvasEditorView({ quickEditSelectionSourceLayer, generationComposerStyle, selectedToolbarStyle, + splittingIconSpritesheetLayerIds, uploadDropTarget, contextMenu, canvasClipboard, diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 9ab35425c..ba91a51c7 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -42,6 +42,7 @@ function renderSelectedToolbar( onOpenRedrawPanel: vi.fn(), onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), + isSplittingIconSpritesheet: false, onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), @@ -155,6 +156,33 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(props.onSplitIconSpritesheet).toHaveBeenCalledWith(layer); }); + it('renders a disabled loading state while splitting an icon spritesheet', () => { + const layer = createLayer({ + sourceType: 'generated', + assetKind: 'icon-spritesheet', + }); + const props = renderSelectedToolbar({ + selectedLayer: layer, + isSplittingIconSpritesheet: true, + }); + const splitButton = screen.getByRole('button', { name: '拆图中' }); + + expect(splitButton.getAttribute('title')).toBe('拆图中'); + expect(splitButton.getAttribute('aria-busy')).toBe('true'); + expect((splitButton as HTMLButtonElement).disabled).toBe(true); + expect(splitButton.textContent).toContain('拆图中'); + expect(splitButton.querySelector('.lucide-loader-circle')).toBeTruthy(); + expect( + splitButton.querySelector('.lucide-loader-circle.animate-spin'), + ).toBeTruthy(); + expect(splitButton.querySelector('.lucide-scissors')).toBeNull(); + + fireEvent.click(splitButton); + fireEvent.click(splitButton); + + expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled(); + }); + it('keeps only remodel and download for audio layers', () => { const layer = createLayer({ title: '游戏音效', @@ -293,6 +321,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + isSplittingIconSpritesheet={false} onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} @@ -310,6 +339,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + isSplittingIconSpritesheet={false} onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 2f0550523..5c187ea95 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -2,6 +2,7 @@ import { Crop, Download, ImageOff, + Loader2, PersonStanding, Scissors, Sparkles, @@ -21,6 +22,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onOpenRedrawPanel: (layer: CanvasLayer) => void; onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; + isSplittingIconSpritesheet: boolean; onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; @@ -34,6 +36,7 @@ export function ImageCanvasSelectedLayerToolbarView({ onOpenRedrawPanel, onOpenCropExpandPanel, onRemoveBackground, + isSplittingIconSpritesheet, onSplitIconSpritesheet, onExtractUiDesignAssets, onOpenCharacterAnimationPanel, @@ -120,12 +123,20 @@ export function ImageCanvasSelectedLayerToolbarView({ {selectedLayer.assetKind === 'icon-spritesheet' ? ( } + label={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'} + title={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'} + icon={ + isSplittingIconSpritesheet ? ( + + ) : ( + + ) + } + disabled={isSplittingIconSpritesheet} + aria-busy={isSplittingIconSpritesheet} onClick={() => onSplitIconSpritesheet(selectedLayer)} > - 拆分图集 + {isSplittingIconSpritesheet ? '拆图中' : '拆分图集'} ) : null} {selectedLayer.assetKind === 'ui-design' ? ( diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index a4c40de95..8fadf766d 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -40,6 +40,8 @@ import type { import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExtractionOverlayView'; import { ImageCanvasWorldView } from './ImageCanvasWorldView'; +const EMPTY_LAYER_ID_SET: ReadonlySet = new Set(); + export type ImageCanvasStageViewProps = { canvasViewportRef: RefObject; specToolWrapRef: RefObject; @@ -73,6 +75,7 @@ export type ImageCanvasStageViewProps = { quickEditSelectionSourceLayer: CanvasLayer | null; generationComposerStyle: CSSProperties | null; selectedToolbarStyle: CSSProperties | null; + splittingIconSpritesheetLayerIds?: ReadonlySet; uploadDropTarget: 'canvas' | 'assets' | null; contextMenu: CanvasContextMenuState | null; canvasClipboard: CanvasClipboard | null; @@ -225,6 +228,7 @@ export function ImageCanvasStageView({ quickEditSelectionSourceLayer, generationComposerStyle, selectedToolbarStyle, + splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, uploadDropTarget, contextMenu, canvasClipboard, @@ -356,6 +360,7 @@ export function ImageCanvasStageView({ cropExpandPanel={cropExpandPanel} cropExpandSourceLayer={cropExpandSourceLayer} generationComposerStyle={generationComposerStyle} + splittingIconSpritesheetLayerIds={splittingIconSpritesheetLayerIds} onLayerPointerDown={onLayerPointerDown} onLayerClick={onLayerClick} onSelectLayer={onSelectLayer} @@ -377,6 +382,10 @@ export function ImageCanvasStageView({ : selectedLayer } selectedToolbarStyle={selectedToolbarStyle} + isSplittingIconSpritesheet={Boolean( + selectedLayer && + splittingIconSpritesheetLayerIds.has(selectedLayer.id), + )} onOpenQuickEditPanel={onOpenQuickEditPanel} onOpenRedrawPanel={onOpenRedrawPanel} onOpenCropExpandPanel={onOpenCropExpandPanel} diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 5c9977ab9..446a3fa26 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -138,6 +138,22 @@ describe('ImageCanvasWorldView', () => { expect(screen.queryByRole('status', { name: '' })).toBeNull(); }); + it('shows the atlas splitting treatment on the source layer', () => { + const layer = createLayer({ assetKind: 'icon-spritesheet' }); + renderWorldView({ + layers: [layer], + splittingIconSpritesheetLayerIds: new Set([layer.id]), + }); + + const sourceLayer = screen.getByRole('button', { + name: `选择${layer.title}`, + }); + expect(sourceLayer.className).toContain( + 'image-canvas-editor__layer--generating', + ); + expect(within(sourceLayer).getByRole('status').textContent).toBe('拆图中'); + }); + it('keeps the image layer frame visible while the image is loading', () => { useResolvedAssetReadUrlMock.mockImplementation(() => ({ resolvedUrl: '', diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 99fafa4fd..97ef196df 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -250,6 +250,7 @@ export type ImageCanvasWorldViewProps = { cropExpandPanel: CropExpandPanelState | null; cropExpandSourceLayer: CanvasLayer | null; generationComposerStyle: CSSProperties | null; + splittingIconSpritesheetLayerIds?: ReadonlySet; onLayerPointerDown: ( event: ReactPointerEvent, layer: CanvasLayer, @@ -285,6 +286,8 @@ export type ImageCanvasWorldViewProps = { ) => void; }; +const EMPTY_LAYER_ID_SET: ReadonlySet = new Set(); + const CROP_EXPAND_HANDLES: Array<{ value: CropExpandResizeHandle; label: string; @@ -916,6 +919,7 @@ export function ImageCanvasWorldView({ cropExpandPanel, cropExpandSourceLayer, generationComposerStyle, + splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, onLayerPointerDown, onLayerClick, onSelectLayer, @@ -1007,10 +1011,13 @@ export function ImageCanvasWorldView({ layer.flipX || layer.flipY ? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})` : undefined; - const layerGeneratingLabel = - generateDialog?.mode === 'edit' && - generateDialog.status === 'generating' && - generateDialog.sourceLayerId === layer.id + const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has( + layer.id, + ) + ? '拆图中' + : generateDialog?.mode === 'edit' && + generateDialog.status === 'generating' && + generateDialog.sourceLayerId === layer.id ? '修改中' : null; const isMediaContainer = isCanvasMediaContainerLayer(layer); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index f226754fe..6012edb28 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -535,6 +535,11 @@ function GenerationWorkflowHarness({ > 拆分图集 + + {workflow.splittingIconSpritesheetLayerIds.has(layers[0]!.id) + ? '拆图中' + : '空闲'} + + + + + ); } @@ -778,7 +861,9 @@ describe('useImageCanvasStageInteractions', () => { act(() => { screen.getByRole('button', { name: '直接移动平移' }).click(); }); - expect(screen.getByTestId('viewport-state').textContent).toBe('30.0,25.0,1'); + expect(screen.getByTestId('viewport-state').textContent).toBe( + '30.0,25.0,1', + ); act(() => { screen.getByRole('button', { name: '清理交互' }).click(); }); @@ -800,9 +885,7 @@ describe('useImageCanvasStageInteractions', () => { render(); act(() => { - screen - .getByRole('button', { name: '直接贴近图层拖生成占位' }) - .click(); + screen.getByRole('button', { name: '直接贴近图层拖生成占位' }).click(); }); expect(screen.getByTestId('dialog-position').textContent).toBe( @@ -844,8 +927,9 @@ describe('useImageCanvasStageInteractions', () => { act(() => { screen.getByRole('button', { name: '直接追加生成器' }).click(); }); - const generationSelectionId = - screen.getByTestId('dialog-selection-id').textContent; + const generationSelectionId = screen.getByTestId( + 'dialog-selection-id', + ).textContent; expect(screen.getByTestId('selection').textContent).toBe( `first:first,${generationSelectionId}`, ); @@ -920,4 +1004,172 @@ describe('useImageCanvasStageInteractions', () => { expect(flushMinimapViewportDrag).toHaveBeenCalledTimes(1); expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2); }); + + it('allows middle-button dragging and rejects right-button dragging in select, hand, and Space modes', () => { + render(); + + const viewport = screen.getByTestId('viewport'); + + act(() => { + fireEvent( + viewport, + new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 100, + button: 1, + buttons: 4, + }), + ); + }); + expect(screen.getByTestId('panning').textContent).toBe('true'); + + act(() => { + fireEvent( + viewport, + new MouseEvent('pointerup', { + bubbles: true, + clientX: 100, + clientY: 100, + button: 1, + buttons: 0, + }), + ); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + + act(() => { + fireEvent( + viewport, + new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 100, + button: 2, + buttons: 2, + }), + ); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + + act(() => { + screen.getByRole('button', { name: '切抓手' }).click(); + }); + expect(screen.getByTestId('tool').textContent).toBe('hand'); + + act(() => { + fireEvent( + viewport, + new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 100, + button: 2, + buttons: 2, + }), + ); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + + act(() => { + screen.getByRole('button', { name: '松开空格' }).click(); + }); + act(() => { + screen.getByRole('button', { name: '按住空格' }).click(); + }); + expect(screen.getByTestId('tool').textContent).toBe('hand'); + + act(() => { + fireEvent( + viewport, + new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 100, + button: 2, + buttons: 2, + }), + ); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + }); + + it('rejects right-click panning on layers in select, hand, and Space modes', () => { + render(); + + act(() => { + screen.getByRole('button', { name: '右键选第一层' }).click(); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + expect(screen.getByTestId('selection').textContent).toBe('-:'); + + act(() => { + screen.getByRole('button', { name: '右键抓手模式选第一层' }).click(); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + expect(screen.getByTestId('tool').textContent).toBe('hand'); + expect(screen.getByTestId('selection').textContent).toBe('-:'); + + act(() => { + screen.getByRole('button', { name: '松开空格' }).click(); + }); + act(() => { + screen.getByRole('button', { name: '按住空格' }).click(); + }); + expect(screen.getByTestId('tool').textContent).toBe('hand'); + + act(() => { + const layer = screen.getByTestId('layer-first'); + fireEvent( + layer, + new MouseEvent('pointerdown', { + bubbles: true, + clientX: 40, + clientY: 40, + button: 2, + buttons: 2, + }), + ); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + expect(screen.getByTestId('selection').textContent).toBe('-:'); + }); + + it('rejects right-click panning on generation frames in select, hand, and Space modes', () => { + render(); + + act(() => { + screen.getByRole('button', { name: '右键拖生成占位' }).click(); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + + act(() => { + screen.getByRole('button', { name: '右键抓手模式拖生成占位' }).click(); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + expect(screen.getByTestId('tool').textContent).toBe('hand'); + + act(() => { + screen.getByRole('button', { name: '松开空格' }).click(); + }); + act(() => { + screen.getByRole('button', { name: '按住空格' }).click(); + }); + expect(screen.getByTestId('tool').textContent).toBe('hand'); + + act(() => { + const frame = screen.getByTestId('generation-frame'); + fireEvent( + frame, + new MouseEvent('pointerdown', { + bubbles: true, + clientX: 300, + clientY: 200, + button: 2, + buttons: 2, + }), + ); + }); + expect(screen.getByTestId('panning').textContent).toBe('false'); + }); }); diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts index 1b94876b2..63ad4c603 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.ts +++ b/src/components/image-editor/useImageCanvasStageInteractions.ts @@ -92,9 +92,7 @@ type UseImageCanvasStageInteractionsOptions = { pickUiDesignSpecFromLayer: (layer: CanvasLayer) => void; pickPublicationReferenceFromLayer: (layer: CanvasLayer) => void; openLayerGenerationDialog?: (layer: CanvasLayer) => boolean; - activateCanvasGenerationDialog: ( - dialog: CanvasGenerationDialogState, - ) => void; + activateCanvasGenerationDialog: (dialog: CanvasGenerationDialogState) => void; updateCanvasGenerationDialogById: ( dialogId: string, updater: ( @@ -216,6 +214,11 @@ export function useImageCanvasStageInteractions({ } }, [finishViewportInteraction, flushMinimapViewportDrag]); + const rejectRightButtonInteraction = useCallback(() => { + // Preserve the pointer event so the matching contextmenu event can still open. + clearActiveInteraction(); + }, [clearActiveInteraction]); + const setShiftPressed = useCallback((pressed: boolean) => { isShiftPressedRef.current = pressed; }, []); @@ -239,12 +242,17 @@ export function useImageCanvasStageInteractions({ const handleCanvasPointerDown = useCallback( (event: ReactPointerEvent) => { const button = getPointerButton(event); - if (button !== 0 || effectiveTool === 'hand') { + if (button === 2) { + rejectRightButtonInteraction(); + return; + } + if (button === 1 || (button === 0 && effectiveTool === 'hand')) { startPan(event); return; } if (button !== 0) { + event.preventDefault(); return; } const target = event.target as HTMLElement; @@ -269,18 +277,29 @@ export function useImageCanvasStageInteractions({ } clearCanvasFocus(); }, - [canvasViewportRef, clearCanvasFocus, effectiveTool, startPan], + [ + canvasViewportRef, + clearCanvasFocus, + effectiveTool, + rejectRightButtonInteraction, + startPan, + ], ); const handleLayerPointerDown = useCallback( (event: ReactPointerEvent, layer: CanvasLayer) => { const button = getPointerButton(event); - if (button === 1 || effectiveTool === 'hand') { + if (button === 2) { + rejectRightButtonInteraction(); + return; + } + if (button === 1 || (button === 0 && effectiveTool === 'hand')) { event.stopPropagation(); startPan(event); return; } if (button !== 0) { + event.preventDefault(); event.stopPropagation(); return; } @@ -409,6 +428,7 @@ export function useImageCanvasStageInteractions({ pickIconSpecFromLayer, pickPublicationReferenceFromLayer, pickUiDesignSpecFromLayer, + rejectRightButtonInteraction, selectedLayerIds, setGenerateDialog, setSelectedLayerId, @@ -481,12 +501,18 @@ export function useImageCanvasStageInteractions({ return; } const button = getPointerButton(event); - if (button === 1 || effectiveTool === 'hand') { + if (button === 2) { + rejectRightButtonInteraction(); + return; + } + if (button === 1 || (button === 0 && effectiveTool === 'hand')) { event.stopPropagation(); startPan(event); return; } if (button !== 0) { + event.preventDefault(); + event.stopPropagation(); return; } @@ -527,6 +553,7 @@ export function useImageCanvasStageInteractions({ canvasGenerationDialogs, effectiveTool, layers, + rejectRightButtonInteraction, selectedLayerIds, setSelectedLayerId, setSelectedLayerIds, @@ -554,6 +581,9 @@ export function useImageCanvasStageInteractions({ const handlePointerMove = useCallback( (event: ReactPointerEvent) => { + if ((event.buttons & 2) !== 0 && (event.buttons & (1 | 4)) === 0) { + return; + } if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) { event.preventDefault(); const rect = canvasViewportRef.current?.getBoundingClientRect();