diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 854c60bd..5ccebe0e 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -2246,3 +2246,51 @@ - 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/editor_project.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、后端数据契约文档和图片画布前端技术方案。 - 验证方式:`npm run spacetime:generate -- --rust-only`、`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:spacetime-schema`、`npm run check:encoding`、`cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`git diff --check`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-06-15 图片画布角色图层新增动画生成入口 + +- 背景:图片画布已有角色形象图层标记 `assetKind="character"`,需要只对角色图片开放动画生成,不让普通素材误触发角色动画链路。 +- 决策:角色动画入口只由画布图层 `assetKind="character"` 控制,在图片上方浮动工具条和右键菜单显示 `生成动画`;非角色图层不展示入口。点击后打开独立 `角色动画生成面板`,桌面端锚定到图片右侧,移动端按底部面板承接。前端固定提交 `seedance2.0`、分辨率 / 比例 / 帧数 / 时长 / 价格字段;后端经 `/api/editor/character-animations/generations` 使用角色图作为首帧和尾帧生成视频,并立即抽取 32 / 40 / 48 帧、绿幕去背后写入 OSS。 +- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/shared-contracts/src/assets.rs`、图片画布技术方案。 +- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-06-16 图片画布图标素材面板采用 Lovart 式参考卡与横向增宽布局 + +- 背景:图标素材生成面板里,规范入口与素材描述项过于平铺,且子面板内部采用滑动列表,和 Lovart 风格画布的参考卡 / 物料卡不一致。 +- 决策:`生成图标素材` 面板不使用内部纵向滚动列表;每新增一个素材描述项就让面板整体增宽,保持描述项横向卡片一眼可扫。图标素材规范入口改为 Lovart 式参考卡:缩略图、名称、绑定状态和轻量动作分区分开呈现,独立菜单只负责来源切换,不再承载说明文案。 +- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/index.css`、图标素材生成专项设计文档。 +- 验证方式:新增或增删素材描述项时,面板宽度应随项数变化;图标素材规范入口应呈现参考卡视觉而非纯文本按钮;移动端下仍应固定在底部锚定,不出现内部滚动条。 +- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-06-16 图片画布图标素材采用 nanobanana2 spritesheet + 后端连通域拆分 + +- 背景:图片画布需要一次生成多枚 UI 图标素材,并保证生成后能按用户输入顺序命名、拆成独立透明素材铺回画布。 +- 决策:底部 `生成图标素材` 入口创建一叠空白图标占位和独立面板;图标规范参考图只允许绑定 `assetKind="icon-spec"`。前端提交 `/api/editor/icon-spritesheets/generations`,后端固定使用 VectorEngine `gemini-3.1-flash-image-preview`,`<=25` 个描述用 `512x512`,`>25` 个描述用 `1024x1024`,先生成绿幕 1:1 spritesheet,再由 `platform-image` 绿幕去背并按 8 邻域连通域从上到下、从左到右拆分。成品图标图层写入 `assetKind="icon"`。 +- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/services/image-editor/editorProjectClient.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、图片画布技术方案。 +- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`cargo test --manifest-path server-rs/Cargo.toml -p platform-image generated_asset_sheets::sheet::tests -- --nocapture`、`cargo test --manifest-path server-rs/Cargo.toml -p api-server editor_project -- --nocapture`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-06-16 图片画布生成面板与浮层层级收口 + +- 背景:图片画布底部工具栏和角色参考图行都存在局部滚动 / 裁切容器,生成规范菜单和角色规范来源菜单如果仍内嵌在触发按钮附近,会被边界遮挡;同时生成类面板打开后隐藏底部工具栏会破坏连续创作节奏。 +- 决策:`生成规范`、`角色形象规范来源` 和 `图标素材规范来源` 菜单统一通过页面级 fixed portal 渲染到 `document.body`,触发按钮只提供定位锚点;点击 `生成工具`、`生成角色形象` 或 `生成图标素材` 后底部 AI 工具栏保持可见。点击画布空白区域只关闭当前生成面板并清除图片选中样式,不删除新建的占位图。角色面板中的 `角色形象规范` 与 `上传常规参考图` 入口统一改为 Lovart 式参考图卡片。 +- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/index.css`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`、图片画布前端技术方案和角色形象生成设计文档。 +- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-06-16 图片画布图片信息页不展示生图 Prompt + +- 背景:图片画布中每张生成图片的信息页原来展示 `Prompt` 和复制 Prompt,但该字段可能是后端组装后的生图提示词,不适合作为用户可见的图片输入信息。 +- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色形象规范、常规参考图、图标素材规范和修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。 +- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、图片画布 layout snapshot、图片画布技术方案。 +- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` 应覆盖图片信息页无 `Prompt`、无 `复制Prompt`,并展示普通生成、角色生成、图标素材和修改结果的输入快照。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-06-16 图片画布按 Resolution 原分辨率显示 + +- 背景:图片画布图层曾同时维护展示 `Size` 与资源 `Resolution`,旧布局快照里的 `width/height` 可能把大图缩成小图,导致画布视觉和图片信息里的原始分辨率不一致。 +- 决策:图片图层不再把独立 `Size` 作为用户可见字段或展示真相;画布图层渲染宽高、悬浮尺寸胶囊和图片信息页统一以 `originalWidth/originalHeight`(即 `Resolution`)为准。旧 layout 中的 `width/height` 只作为缺少 Resolution 时的兼容兜底,不再优先决定展示大小。 +- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、图片画布 layout hydrate、新建 / 上传 / 生成 / 快速编辑 / 图标素材生成结果铺回画布逻辑,以及图片画布技术方案。 +- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "hydrates canvas images from Resolution instead of saved Size|opens generated image info from the corner button and creates a real right-side edit result|shows image resolution on hover"`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index eb41f0e5..0be1a973 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1,4 +1,4 @@ -# 踩坑与排障记录 +# 踩坑与排障记录 > 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。 @@ -15,6 +15,62 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 图片编辑器底部生成按钮不要复用单一画布生成状态 + +- 现象:图片画布里先新建一个“生成规范”占位,再点击“生成角色形象”或其它底部生成入口,前一个规范占位和面板状态被销毁。 +- 原因:底部普通生成、规范、角色和图标素材曾共用单个 `generateDialog` 状态;后一次点击直接覆盖该状态,等同把前一个画布生成对象卸载。 +- 处理:底部生成类入口每次点击都创建独立 generation dialog id;当前 active 对象只负责显示编辑面板,旧对象归档为 inactive 后仍保留占位和生成逻辑状态。生成完成 / 失败回写、生成中拖拽和删除都必须按 dialog id 读取 active + inactive 中的最新对象,不能回退到提交瞬间的旧占位快照。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps existing generation placeholders"` 应断言规范占位和角色占位可同时存在;`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps archived generation logic"` 应断言旧对象归档后拖动,占位完成回写仍落在最新位置。 +- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 图片编辑器生成中设定面板不要和预览框绑成同一可见性 + +- 现象:图片编辑器里点击生成后,有时设定面板没收起,有时连画布上的占位预览一起消失,看起来像“生成中界面掉了”。 +- 原因:生成中状态只收了 composer 可见性,或把占位框和设定面板共用了同一段条件渲染;面板隐藏后把 placeholder 也一起卸掉,就会丢掉 Lovart 式生成中预览。 +- 处理:进入 `generating` 后只隐藏设定面板,保留占位框和生成中状态胶囊;面板外观、预览框和结果图层分开控制,不共用同一个 `composerOpen` 条件。 +- 验证:对应测试应断言生成按钮点击后 `dialog` 消失但 `image-canvas-editor__generation-frame--generating` 仍然存在。 +- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。 + +## 图片画布快速编辑不要直接提交普通图片 URL + +- 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`。 +- 原因:快速编辑直接把图层 `src` 塞进 `/api/editor/images/generations` 的 `referenceImageSrcs`;默认示例图和部分持久化图层的 `src` 是 `/creation-type-references/*.webp`、`/generated-*` 或 OSS URL,而 `api-server` 的编辑参考图解析只接收 `data:image/*;base64,...`。 +- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 走 `/api/assets/read-bytes` 避免 CORS,普通 public 路径直接 fetch。 +- 验证:`npm run test -- src/services/image-editor/editorImageReference.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx -t "editorImageReference|converts non-data-url quick edit source images before submitting references"`。 +- 关联:`src/services/image-editor/editorImageReference.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 图片编辑器角色动画不要默认提交大图 Data URL + +- 现象:图片编辑器里对角色图点击 `生成动画` 后,后端返回 `Failed to buffer the request body: length limit exceeded`,请求还没进入角色动画 handler。 +- 原因:角色动画生成请求曾把角色图片 `src` 原样作为 `sourceImageSrc` 放进 JSON;角色图如果是较大的 Data URL,会超过 Axum 默认 `2MB` body limit,在 `Json` 提取器阶段被拦截。 +- 处理:前端在角色图已持久化时优先提交 `objectKey`,只把 Data URL 作为未持久化本地临时图兜底;后端 `/api/editor/character-animations/generations` 单独配置 `12MB` body limit 兼容旧请求,但新链路不应依赖传大图 JSON。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "only exposes character animation"`;`cargo test -p api-server editor_character_animation_accepts_character_image_body_above_default_limit --manifest-path server-rs/Cargo.toml`。 +- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`server-rs/crates/api-server/src/modules/play_flow.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 + +## 图片编辑器生成类菜单要挂到页面级 portal + +- 现象:底部 `生成规范` 菜单、角色面板里的 `角色形象规范` 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。 +- 原因:菜单仍然渲染在底部工具栏或参考图横向滚动行内部,父容器带 `overflow`,弹层无法越出边界。 +- 处理:这类轻量菜单统一用页面级 fixed portal 挂到 `document.body`,位置根据触发按钮的 `getBoundingClientRect()` 计算;底部 AI 工具栏在生成面板打开时仍保持可见,不要整栏隐藏。 +- 验证:测试断言菜单不包含在底部工具栏 / 参考图行里,并且生成面板打开时底部 `AI画布工具栏` 仍存在。 +- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。 + +## 图片编辑器生成占位图在生成中也要使用最新拖拽位置 + +- 现象:用户在图片编辑器里提交生成后继续拖动画布占位图,预览框可以移动,但生成完成后的真实图片仍落回提交瞬间的旧位置。 +- 原因:生成提交函数闭包里保存了旧的 `dialog.placeholder` 快照;如果完成回包仍用这个快照创建图层,就会丢失生成中期间的拖拽坐标。若 `handleGenerationFramePointerDown` 又按 `status === 'generating'` 拦截,则生成中占位图完全不能拖动。 +- 处理:生成占位图的 pointer down 不因 `generating` 禁止;普通图片、规范图、角色图和图标素材回包创建图层时,都从当前 `generateDialogRef.current.placeholder` 读取最新占位位置,失败后保留的占位图也继续走同一拖拽链路。 +- 验证:`npm test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps the generation placeholder draggable while the image is generating"`。 +- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## Windows 本地 dev 不要把 RUSTC_WRAPPER 绕过写成 rustc + +- 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)`,`/healthz` 无法访问。 +- 原因:`server-rs/.cargo/config.toml` 默认配置 `rustc-wrapper = "sccache"`;本地 dev 脚本为了绕过损坏的 sccache 需要覆盖 wrapper。Windows 下如果把 `RUSTC_WRAPPER` 设置为 `rustc`,Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,真实 rustc 把 wrapper 传入的 rustc 路径和 stdin `-` 都当输入文件。 +- 处理:Windows 本地 dev 脚本应把 `RUSTC_WRAPPER` 和 `CARGO_BUILD_RUSTC_WRAPPER` 显式设为空字符串,让 Cargo 覆盖项目配置并直连真实 rustc;Linux 保持 `/usr/bin/env` 绕过 sccache。 +- 验证:`npm run test -- scripts/dev.test.ts -t "Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache"`,并用 `npm run dev:api-server` 拉起后访问实际 api 端口的 `/healthz` 返回 200。 +- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 外部生成 worker 业务失败重试会撞上钱包扣退费幂等 - 现象:同一个外部生成 job 如果第一次业务失败后退款,再用同一个业务资源 ID 自动重试并成功,钱包 `consume` ledger 可能因为同 ID 已存在而跳过,最终出现“失败已退、成功不再扣”的余额漂移。 diff --git a/TRACKING.md b/TRACKING.md index 1f352b27..1f5c9e0e 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -108,3 +108,4 @@ - 2026-06-14 组件复用修正:编辑器侧栏素材和图层缩略图通过 `SidebarMediaItem` 改为复用 `PlatformMediaFrame`,删除缩略图内部图片填充的重复 CSS,统一媒体预览框和 fallback 结构;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformMediaFrame.test.tsx`、`npm run typecheck`。 - 2026-06-14 组件复用修正:画布图片 hover 尺寸标签改为复用 `PlatformPillBadge tone="lightOverlay"`,局部 CSS 只保留定位和深色覆盖,不再重复维护 badge 的圆角、字号和基础排版;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformPillBadge.test.tsx`、`npm run typecheck`。 - 2026-06-14 组件复用修正:生成跟随框的关闭按钮改为复用 `PlatformIconButton variant="surfaceFloating"`,编辑器薄包装 `EditorIconButton` 增加 variant 透传,删除局部关闭按钮基础 chrome;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformIconButton.test.tsx`、`npm run typecheck`。 +- 2026-06-16 编辑器回归修正:工程 / 素材 / 上传等编辑器请求恢复全局 401 / 403 登录弹窗;未登录上传会先弹登录并在登录后续传;画布背景入口恢复为 `画布背景设置` 面板,支持预设色、自定义颜色、HEX 输入、非法值不应用、恢复默认和 Escape 关闭。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器 smoke:`http://127.0.0.1:10006/editor/canvas` 未登录打开 `账号入口`,登录后上传素材成功,背景面板打开后点击“暖灰”使画布背景变为 `rgb(243, 240, 234)`。 diff --git a/docs/superpowers/plans/2026-06-16-editor-image-model-options.md b/docs/superpowers/plans/2026-06-16-editor-image-model-options.md new file mode 100644 index 00000000..0d0163b3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-editor-image-model-options.md @@ -0,0 +1,146 @@ +# Editor Image Model Options Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让图片画布的“生成角色形象”和“生成图标素材”支持 `nanobanana2` 与 `gpt-image-2`,并按模型提供合法的尺寸比例与大小尺寸选项。 + +**Architecture:** 前端抽出编辑器图片模型配置,生成面板只保存模型、比例、大小三个轻量状态;后端集中归一模型和尺寸组合,角色图与图标 spritesheet 继续走现有 VectorEngine、去背、OSS 和拆分链路。用户模型偏好用 localStorage 记住,默认 `nanobanana2`。 + +**Tech Stack:** React + TypeScript + Vitest;Rust Axum api-server;platform-image VectorEngine provider;Markdown 项目文档。 + +--- + +## File Structure + +- Modify `C:\Genarrative\src\services\image-editor\editorProjectClient.ts` + - 扩展图片生成和图标 spritesheet 请求类型,加入 `aspectRatio` 与 `imageSize`。 + - 默认图标模型改为 `gemini-3.1-flash-image-preview` 对应的 `nanobanana2`。 +- Modify `C:\Genarrative\src\services\image-editor\editorProjectClient.test.ts` + - 先补失败测试:角色 / 图标请求会带模型、比例、大小。 +- Modify `C:\Genarrative\src\components\image-editor\ImageCanvasEditorView.tsx` + - 增加模型配置、选项归一、localStorage 偏好、角色 / 图标面板字段和提交 payload。 +- Modify `C:\Genarrative\src\components\image-editor\ImageCanvasEditorView.test.tsx` + - 先补失败测试:默认显示 nanobanana2,切换模型后比例 / 大小选项变更,并在请求中传递。 +- Modify `C:\Genarrative\server-rs\crates\platform-image\src\vector_engine\request.rs` + - 让 `512` 不被 normalize 成非法尺寸,并保留 gpt-image-2 尺寸。 +- Modify `C:\Genarrative\server-rs\crates\platform-image\tests\vector_engine.rs` + - 先补失败测试:nanobanana2 0.5K 请求 body 保留 `512`。 +- Modify `C:\Genarrative\server-rs\crates\api-server\src\editor_project.rs` + - 扩展请求 DTO,集中校验 `nanobanana2 / gpt-image-2` 与尺寸组合。 + - 角色生成按模型走 with_model 调用;图标生成按模型和组合选择尺寸。 +- Modify docs: + - `C:\Genarrative\docs\【编辑器】画板角色形象生成入口设计-2026-06-15.md` + - `C:\Genarrative\docs\【编辑器】画板图标素材生成入口设计-2026-06-15.md` + - `C:\Genarrative\docs\technical\【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` + +--- + +### Task 1: Client request contract + +**Files:** +- Modify: `C:\Genarrative\src\services\image-editor\editorProjectClient.ts` +- Test: `C:\Genarrative\src\services\image-editor\editorProjectClient.test.ts` + +- [ ] **Step 1: Write failing tests** + +Add tests asserting `generateEditorImage` and `generateEditorIconSpritesheet` serialize `model`, `aspectRatio`, and `imageSize` when supplied. + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/services/image-editor/editorProjectClient.test.ts -t "image model options"` +Expected: FAIL because payloads do not include `aspectRatio` / `imageSize`. + +- [ ] **Step 3: Minimal implementation** + +Extend input types and JSON body builders to include optional `aspectRatio` and `imageSize`; change icon default model constant to `gemini-3.1-flash-image-preview` if not already. + +- [ ] **Step 4: Verify green** + +Run same test command. Expected: PASS. + +### Task 2: Frontend panel state and local preference + +**Files:** +- Modify: `C:\Genarrative\src\components\image-editor\ImageCanvasEditorView.tsx` +- Test: `C:\Genarrative\src\components\image-editor\ImageCanvasEditorView.test.tsx` + +- [ ] **Step 1: Write failing tests** + +Add tests for: +1. opening `生成角色形象` defaults to model `nanobanana2` and shows `尺寸比例` / `大小尺寸`; +2. switching to `gpt-image-2` limits visible combinations and submits model + mapped size metadata; +3. icon spritesheet defaults to `nanobanana2` and submits the chosen model. + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "模型|尺寸比例|大小尺寸"` +Expected: FAIL because current UI has placeholder model button only. + +- [ ] **Step 3: Minimal implementation** + +Add model option config, dialog fields `imageModel/aspectRatio/imageSize`, localStorage helpers, option buttons/selects, and submit payload wiring. + +- [ ] **Step 4: Verify green** + +Run same test command. Expected: PASS. + +### Task 3: Backend model and dimension normalization + +**Files:** +- Modify: `C:\Genarrative\server-rs\crates\platform-image\src\vector_engine\request.rs` +- Modify: `C:\Genarrative\server-rs\crates\api-server\src\editor_project.rs` +- Test: `C:\Genarrative\server-rs\crates\platform-image\tests\vector_engine.rs` +- Test: existing unit tests inside `editor_project.rs` + +- [ ] **Step 1: Write failing tests** + +Add tests covering: +1. `build_vector_engine_image_request_body_with_model("gemini-3.1-flash-image-preview", ..., "512", ...)` keeps `size = "512"`. +2. editor character model normalization defaults to nanobanana2 and maps `gpt-image-2 + 2:3 + 1K` to `1024x1536`. +3. icon spritesheet model normalization accepts both models. + +- [ ] **Step 2: Run backend tests to verify failure** + +Run: +`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_request_body_can_use_nanobanana2_half_k -- --nocapture` +`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project -- --nocapture` +Expected: FAIL until normalization functions exist. + +- [ ] **Step 3: Minimal implementation** + +Add constants and helpers: +- `EDITOR_IMAGE_MODEL_NANOBANANA2 = "gemini-3.1-flash-image-preview"` +- `EDITOR_IMAGE_MODEL_GPT_IMAGE_2 = "gpt-image-2"` +- `normalize_editor_image_model` +- `normalize_editor_generation_dimensions` +Use with_model calls for character and icon generation responses. + +- [ ] **Step 4: Verify green** + +Run the same backend tests. Expected: PASS. + +### Task 4: Documentation and final checks + +**Files:** +- Modify docs listed above. + +- [ ] **Step 1: Update docs** + +Document defaults, user preference, model-specific options, and Apifox source URLs. + +- [ ] **Step 2: Run focused verification** + +Run: +`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx` +`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_request_body_can_use_nanobanana2_half_k -- --nocapture` +`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project -- --nocapture` +`npm run typecheck -- --pretty false` +`npm run check:encoding` + +--- + +## Self-Review + +- Spec coverage: covers role generation, icon spritesheet generation, default model, user preference, model-specific dimensions, docs. +- Placeholder scan: no unresolved placeholders. +- Type consistency: frontend uses `model/aspectRatio/imageSize`; backend DTO mirrors camelCase fields. diff --git a/docs/superpowers/plans/【编辑器】图片信息生成输入快照落地计划-2026-06-16.md b/docs/superpowers/plans/【编辑器】图片信息生成输入快照落地计划-2026-06-16.md new file mode 100644 index 00000000..df28af89 --- /dev/null +++ b/docs/superpowers/plans/【编辑器】图片信息生成输入快照落地计划-2026-06-16.md @@ -0,0 +1,84 @@ +# 图片信息生成输入快照 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 图片画布编辑器的图片信息页删除生图 Prompt 字段,改为展示图片生成时的面板输入快照,包括文本字段和参考图。 + +**Architecture:** 在 `CanvasLayer` 上新增轻量 `generationInputs` 前端快照,只保存用户可见输入和参考图摘要;生成成功时从对应面板状态构建快照,随画布 layout JSON 持久化。图片信息弹窗只读取该快照渲染,不回退显示后端组装 Prompt。 + +**Tech Stack:** React + TypeScript + Vitest + Testing Library;现有 `UnifiedModal`、平台按钮和图片画布 layout snapshot。 + +--- + +### Task 1: 信息弹窗行为测试 + +**Files:** +- Test: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.test.tsx` + +- [ ] **Step 1: Write the failing tests** + - 修改已有图片信息测试,断言弹窗不出现 `Prompt` 和 `复制Prompt`。 + - 新增普通生成图片测试:生成后打开信息页,应显示 `生成输入`、`生成提示词` 和用户输入值。 + - 新增角色生成图片测试:绑定角色规范参考图后生成,信息页应显示 `角色设定`、`角色形象规范` 与参考图名称。 + - 新增图标素材生成测试:绑定图标素材规范后生成,信息页应显示 `素材描述`、具体描述和 `图标素材规范` 参考图。 + +- [ ] **Step 2: Run test to verify it fails** + - Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` + - Expected: FAIL because `generationInputs` is not yet implemented and old `Prompt` field still exists. + +### Task 2: 生成输入快照模型与持久化 + +**Files:** +- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx` + +- [ ] **Step 1: Add minimal types** + - Add `CanvasGenerationInputField`, `CanvasGenerationInputReference`, `CanvasGenerationInputs`. + - Add optional `generationInputs` to `CanvasLayer`. + +- [ ] **Step 2: Serialize and hydrate** + - Include `generationInputs` in `serializeLayer`. + - Hydrate only trusted shapes: string `title`, string `value`, string `label`, string `src`. + +### Task 3: Build snapshots when creating generated layers + +**Files:** +- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx` + +- [ ] **Step 1: Add builders** + - `buildGenerationInputsForImagePrompt(prompt)`. + - `buildGenerationInputsForSpec(specType, specValues)`. + - `buildGenerationInputsForCharacter(prompt, specRef, references)`. + - `buildGenerationInputsForIcon(iconDescriptions, iconSpecRef)`. + - `buildGenerationInputsForEdit(prompt, sourceLayer)`. + +- [ ] **Step 2: Attach snapshots** + - Pass `generationInputs` into `addGeneratedResultLayer`, `addQuickEditResultLayer`, and `addIconSpritesheetResultLayers`. + - Keep old `prompt/actualPrompt` for backend metadata and backward compatibility, but do not render them in UI. + +### Task 4: Render image info without生图 Prompt + +**Files:** +- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx` +- Modify: `C:/Genarrative/src/index.css` if existing metadata styles need a reference grid helper. + +- [ ] **Step 1: Replace Prompt row** + - Remove `Prompt` dt/dd and `复制Prompt` button. + - Render `生成输入` row. + +- [ ] **Step 2: Render fields and references** + - If `generationInputs` has fields, render each field title/value. + - If it has references, render thumbnail cards with title and image. + - If both empty or absent, render `-`. + +### Task 5: Documentation and verification + +**Files:** +- Modify: `C:/Genarrative/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` + +- [ ] **Step 1: Update documentation** + - Add note: image info displays panel input snapshot and references, never assembled generation Prompt. + +- [ ] **Step 2: Run verification** + - Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` + - Run: `npm run typecheck` + - Run: `npm run check:encoding` + - Run: `git diff --check` diff --git a/docs/superpowers/plans/【编辑器】图片画布生成对象独立化修复计划-2026-06-16.md b/docs/superpowers/plans/【编辑器】图片画布生成对象独立化修复计划-2026-06-16.md new file mode 100644 index 00000000..c5c4216a --- /dev/null +++ b/docs/superpowers/plans/【编辑器】图片画布生成对象独立化修复计划-2026-06-16.md @@ -0,0 +1,106 @@ +# 图片画布生成对象独立化修复 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** 修复图片画布底部生成按钮复用单一状态导致后创建对象销毁前一个对象的问题。 + +**Architecture:** 保留现有图片画布组件结构,先以回归测试锁定“规范占位 + 角色占位可并存”。实现上把生成占位状态从单个 `generateDialog` 扩展为 active dialog + inactive dialog 列表;每次新建生成对象只新增一个 dialog 实例,旧实例保留占位和逻辑状态,只有当前 active 实例渲染编辑面板。 + +**Tech Stack:** React、TypeScript、Vitest、Testing Library。 + +--- + +### Task 1: 补充失败回归测试 + +**Files:** +- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.test.tsx` + +- [x] **Step 1: Write the failing test** + +在 `keeps the bottom AI toolbar visible while generation panels are open` 附近新增测试: + +```tsx +it('keeps existing generation placeholders when another bottom generation object is created', () => { + render(); + + const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); + fireEvent.click( + within(bottomToolbar).getByRole('button', { name: '生成规范' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' })); + + expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); + expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + + expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); + expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); + expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); +}); +``` + +- [x] **Step 2: Run test to verify it fails** + +Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps existing generation placeholders"` + +Expected: FAIL because only the latest placeholder remains. + +### Task 2: 实现最小独立生成对象状态 + +**Files:** +- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx` + +- [x] **Step 1: Add stable dialog ids and inactive dialog state** + +Add `id: string` to `GenerateDialogState`; add `generationDialogCounterRef`; add `inactiveGenerateDialogs` state. Provide helpers to create ids, archive current active dialog before replacing it, update active/inactive dialogs by id, and list all canvas generation dialogs. + +- [x] **Step 2: Update bottom generation openers** + +Change `openGenerateDialog`、`openSpecDialog`、`openCharacterGenerationDialog`、`openIconGenerationDialog` so each call archives the current active canvas generation dialog and sets a newly created active dialog. Edit modal remains single active dialog and does not archive. + +- [x] **Step 3: Render all placeholders** + +Replace the single placeholder render block with a map over inactive dialogs plus active dialog. Only active dialog shows composer; inactive dialogs remain visible and can be clicked to reactivate their own panel. + +- [x] **Step 4: Keep actions scoped to active dialog** + +Keep submit/update/upload/pick actions operating on active dialog only. Adjust delete, drag, blur, and generated-layer cleanup so they update or remove only the matching active/inactive dialog. + +- [x] **Step 5: Keep archived async generation writeback scoped by dialog id** + +当一个生成对象已经进入 `generating`,随后用户再创建第二个生成对象并把第一个对象归档为 inactive 时,第一个对象仍可能继续被拖拽或等待异步完成。完成回写必须按 `dialog.id` 从 active + inactive 的最新状态读取占位图,不能使用提交瞬间的旧 `placeholder` 快照。 + +回归测试: + +```bash +npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps archived generation logic" +``` + +### Task 3: 验证并更新文档 + +**Files:** +- Modify: `C:/Genarrative/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` +- Optional Modify: `C:/Genarrative/.hermes/shared-memory/pitfalls.md` + +- [x] **Step 1: Run focused tests** + +Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "keeps existing generation placeholders|opens character spec generation form|opens icon asset generation panel|removes the active character generation placeholder"` + +Expected: PASS. + +- [x] **Step 2: Run full image editor test** + +Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` + +Expected: PASS. + +- [x] **Step 3: Run encoding check** + +Run: `npm run check:encoding` + +Expected: PASS. + +- [x] **Step 4: Document behavior** + +Add one sentence to the image canvas editor technical plan: bottom generation buttons create independent canvas generation objects; creating a new one must not destroy previous placeholders or generated-object logic. diff --git a/docs/superpowers/plans/【编辑器】画板角色动画生成实施计划-2026-06-15.md b/docs/superpowers/plans/【编辑器】画板角色动画生成实施计划-2026-06-15.md new file mode 100644 index 00000000..1b5513d8 --- /dev/null +++ b/docs/superpowers/plans/【编辑器】画板角色动画生成实施计划-2026-06-15.md @@ -0,0 +1,128 @@ +# 画板角色动画生成 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在图片画布编辑器中,仅对角色图片提供角色动画生成入口,并通过后端 seedance2.0 链路生成视频、抽帧、去绿幕并持久化到 OSS。 + +**Architecture:** 前端在 `ImageCanvasEditorView` 中基于图层 `assetKind === "character"` 控制悬浮按钮和右键菜单,打开锚定到图片右侧的独立动画生成面板。前端 service 调用新增编辑器角色动画 API,后端复用 `character_animation_assets.rs` 中现有视频生成、抽帧、绿幕去背、OSS 写入能力,避免新建平行资产系统。 + +**Tech Stack:** React + TypeScript + Vitest;Rust Axum `api-server`;现有 `shared-contracts` 资产 DTO;Aliyun OSS 资产持久化;VectorEngine/Ark seedance2.0 角色动画链路。 + +--- + +### Task 1: 文档补充 + +**Files:** +- Modify: `C:/Genarrative/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md` + +- [ ] **Step 1: 补充角色动画生成章节** + - 明确仅 `assetKind: "character"` 图层展示入口。 + - 明确右侧独立面板字段、预设动作、价格、模型、抽帧和 OSS 存储口径。 + - 明确非角色图层不展示按钮。 + +- [ ] **Step 2: 运行编码检查** + - Run: `npm run check:encoding` + - Expected: PASS 或仅与本任务无关的既有问题。 + +### Task 2: 前端失败测试 + +**Files:** +- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.test.tsx` +- Modify: `C:/Genarrative/src/services/image-editor/editorProjectClient.test.ts` + +- [ ] **Step 1: 写失败测试** + - 测试角色图层显示悬浮 / 右键 `生成动画`。 + - 测试非角色图层不显示 `生成动画`。 + - 测试面板提交请求包含 `sourceLayerId`、`sourceImageSrc`、prompt、resolution、ratio、frameCount、durationSeconds、priceMudPoints、model。 + +- [ ] **Step 2: 验证 RED** + - Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts` + - Expected: FAIL,失败原因是功能/API 尚未实现。 + +### Task 3: 前端实现 + +**Files:** +- Modify: `C:/Genarrative/src/services/image-editor/editorProjectClient.ts` +- Modify: `C:/Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx` +- Modify: `C:/Genarrative/src/index.css` + +- [ ] **Step 1: 新增 service 类型和请求函数** + - `generateEditorCharacterAnimation(input)` 调用 `/api/editor/character-animations/generations`。 + - 限定 model 固定为 `seedance2.0` 的回包展示字段。 + +- [ ] **Step 2: 扩展图层 assetKind** + - `CanvasLayer.assetKind` 支持 `'character' | 'spec' | null`。 + - hydrate / serialize / 图片类型展示跟随扩展。 + +- [ ] **Step 3: 加入口和面板** + - 角色图层悬浮工具条和右键菜单显示 `生成动画`。 + - 面板锚定图片右侧,字段按设计实现,文本框 maxLength=4000。 + - 价格通过 `resolution * durationSeconds` 计算。 + +- [ ] **Step 4: 验证 GREEN** + - Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts` + - Expected: PASS。 + +### Task 4: 后端失败测试 + +**Files:** +- Modify: `C:/Genarrative/server-rs/crates/shared-contracts/src/assets.rs` +- Modify: `C:/Genarrative/server-rs/crates/api-server/src/character_animation_assets.rs` +- Modify: `C:/Genarrative/server-rs/crates/api-server/src/modules/play_flow.rs` 或现有 editor router 文件(按现有路由事实选择) + +- [ ] **Step 1: 写 DTO / prompt / plan 单测** + - 验证请求 480p/720p、32/40/48 帧、比例枚举、模型固定 seedance2.0。 + - 验证构造 prompt 包含用户给定固定骨架与动作描述。 + - 验证价格计算:480p 每秒 10,720p 每秒 20。 + +- [ ] **Step 2: 验证 RED** + - Run: `cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml` + - Expected: FAIL,失败原因是 helper 或 handler 尚未实现。 + +### Task 5: 后端实现 + +**Files:** +- Modify: `C:/Genarrative/server-rs/crates/shared-contracts/src/assets.rs` +- Modify: `C:/Genarrative/server-rs/crates/api-server/src/character_animation_assets.rs` +- Modify: `C:/Genarrative/server-rs/crates/api-server/src/modules/play_flow.rs` 或现有 editor router 文件 + +- [ ] **Step 1: 新增编辑器角色动画 DTO** + - 请求字段:sourceLayerId/sourceImageSrc/promptText/resolution/ratio/frameCount/durationSeconds/sourceWidth/sourceHeight。 + - 响应字段:taskId/model/prompt/previewVideoPath/frames/priceMudPoints。 + +- [ ] **Step 2: 新增 handler** + - 校验 prompt 1..4000、resolution、ratio、frameCount 与 durationSeconds 组合。 + - sourceImageSrc 作为首尾帧参考。 + - 调用现有 seedance image-to-video 逻辑生成预览视频。 + - 调用现有抽帧 + 绿幕去背 + OSS 持久化逻辑输出帧。 + +- [ ] **Step 3: 路由接入** + - `POST /api/editor/character-animations/generations`。 + - 保持走 play_flow 创作/游玩支撑主干或 editor 路由现有聚合,不回到 `app.rs` 平行挂载。 + +- [ ] **Step 4: 验证 GREEN** + - Run: `cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml` + - Expected: PASS。 + +### Task 6: 总验证与收口 + +**Files:** +- All modified files. + +- [ ] **Step 1: 定向前端测试** + - Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts` + - Expected: PASS。 + +- [ ] **Step 2: 定向后端测试 / check** + - Run: `cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml` + - Run: `cargo check -p api-server --manifest-path server-rs/Cargo.toml` + - Expected: PASS。 + +- [ ] **Step 3: 类型与编码** + - Run: `npm run typecheck` + - Run: `npm run check:encoding` + - Expected: PASS。 + +- [ ] **Step 4: 检查 diff** + - Run: `git diff --check` + - Expected: PASS。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index f11eb11b..6f9dfa1d 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -12,13 +12,14 @@ - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 -- 画布中的图片可展示、悬浮显示图片尺寸与边框,点击后在图片上方显示浮动工具栏。 +- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成、局部修改 / 蒙版、文字、形状 / 标注、导出。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。 -- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。 +- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色形象规范 / 常规参考图 / 图标素材规范 / 修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行修改时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine `gpt-image-2`:纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;提交成功后真实生成图落在占位框位置,输入框继续跟随新生成图;基于已有生成图的修改仍通过轻量弹窗承载。前端不持有 provider 密钥;上游失败或配置缺失时只在当前生成输入框展示失败,不创建 mock 成功图。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine `gpt-image-2`:纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图落在最新占位框位置,输入框继续跟随新生成图;点击所有图片生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 ## 交互规则 @@ -40,7 +41,7 @@ - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。 - 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 - 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。 -- 资源表只保存资源元数据;图层位置、尺寸、缩放、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。图层组第一版是画布内布局语义,不单独建表。 +- 资源表只保存资源元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 @@ -61,8 +62,8 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,支持按钮上传和拖拽上传后的 data URL / 后续 OSS 元数据。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine `gpt-image-2` 生成图片,返回 data URL、尺寸、prompt、model、provider 和 taskId。 -- `POST /api/editor/images/edits`:按提示词和当前生成图 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine `gpt-image-2` 生成图片;携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。 +- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。 所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。 @@ -82,8 +83,11 @@ - 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。 - 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。 - 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 -- 生成资源显示元数据按钮,元数据窗口展示来源、prompt、model、provider、task、尺寸和 OSS 引用。 +- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。 +- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 +- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。 - 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。 +- 快速编辑站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 - 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。 - 上传按钮和拖拽上传都支持多文件;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入默认文件夹并在投放点创建画布图层。 - 素材面板支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 8e9a5ba7..86218a51 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -49,6 +49,8 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;需要确认实例可接生产流量时检查 `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 +Windows 本地 `npm run dev` / `npm run dev:api-server` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。 + 开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 本地 `npm run dev` 和 `npm run dev:api-server` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor,完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md new file mode 100644 index 00000000..b49d87ae --- /dev/null +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -0,0 +1,77 @@ +# 画板图标素材生成入口设计 + +日期:`2026-06-15` + +## 背景 + +图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,先生成一张绿幕 spritesheet,再自动去背、按连通域拆分为独立图标素材并铺到画布。 + +## 入口与画布表现 + +- 底部 AI 画布工具栏新增 `生成图标素材` 按钮。 +- 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。 +- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。 +- 生成完成后删除占位态,把拆分出的每个独立图标素材作为画布图片图层铺开,图标之间不重叠,并保留少量间距。 +- 图标素材图层写入 `assetKind: "icon"`;图标素材规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。 + +## 面板结构 + +1. 第一模块为 `图标素材规范`。 + - 点击后弹出菜单:`从画布中选择`、`新建图标素材规范`、`上传图片`。 + - `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标素材规范图片;其它图片点击无效。 + - `新建图标素材规范` 复用生成规范表单,规格类型为 `图标素材规范`,生成成功后图层标记为 `icon-spec`。 + - `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布。 +2. 第二模块为素材描述列表。 + - 每个文本框输入一个素材描述。 + - 默认填入:`返回按钮`、`设置按钮`、`下一关按钮`、`提示按钮`、`原图按钮`、`冻结按钮`。 + - 可以继续添加新的素材描述框,最多 `100` 个。 + - 生成时过滤空文本,按面板从上到下顺序作为 prompt 的素材清单。 + +## 面板外观 + +- 图标素材面板不使用内部纵向滑动列表;素材描述项按横向卡片铺开,新增一项就让面板整体更宽,保持列表一眼可扫。 +- 图标素材规范入口采用 Lovart 式参考卡:左侧预览缩略图,中间显示当前绑定名称,右侧显示绑定状态和三个轻量动作入口,不再只是两行文字平铺。 +- 规范卡的 `从画布中选择 / 新建图标素材规范 / 上传图片` 继续保留独立菜单,但菜单只负责来源切换,不承载说明文案。 + +## 生成契约 + +- 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 +- 请求字段: + - `referenceImageSrc`:图标素材规范 Data URL。 + - `iconDescriptions`:过滤空文本后的图标描述数组,`1..100`。 + - `model`:固定 `gemini-3.1-flash-image-preview`。 +- 后端根据图标数量选择尺寸: + - `<=25` 个:`512x512`,即 0.5K 1:1。 + - `>25` 个:`1024x1024`,即 1K 1:1。 +- 后端使用 VectorEngine 图片编辑接口,把 `referenceImageSrc` 作为参考图 1,模型固定传 `gemini-3.1-flash-image-preview`。 +- Prompt 固定为: + +```text +参考图1的图标素材规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet: + +<素材描述按中文顿号拼接> +``` + +## 去背与拆分 + +- 后端收到 spritesheet 后先复用 `platform-image::generated_asset_sheets` 的绿幕去背能力。 +- 去背后基于 alpha 可见像素执行 8 邻域连通域检测。 +- 连通域按从上到下、从左到右排序;排序结果依次绑定面板中的素材描述名称。 +- 每个连通域裁剪时保留少量透明边距,并输出独立透明 PNG Data URL。 +- 若模型返回的连通域数量少于素材描述数量,接口返回失败,不在画布上铺半成品,避免名称和图标错位。 + +## 前端铺放规则 + +- 第一张图标放在原占位图左上附近。 +- 后续图标按行铺开,图标之间保留约 `24px` 世界坐标间距。 +- 每个图标图层标题使用对应素材描述文本。 +- 生成成功后关闭图标素材面板,选中第一张图标素材,并打开图层面板。 + +## 验收 + +- 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。 +- `图标素材规范 -> 从画布中选择` 只能选择图标素材规范图,点击普通图片或角色规范图不会绑定。 +- 默认 6 个素材描述会进入 prompt;新增描述最多到 100 个。 +- `<=25` 个描述提交时后端请求尺寸为 `512x512`;`>25` 个描述提交时后端请求尺寸为 `1024x1024`。 +- VectorEngine 请求体的 `model` 为 `gemini-3.1-flash-image-preview`。 +- 生成成功后画布出现按描述命名的多个透明图标素材图层,图层之间不重叠。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md new file mode 100644 index 00000000..de46aec8 --- /dev/null +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -0,0 +1,124 @@ +# 画板角色形象生成入口设计 + +日期:`2026-06-15` + +## 背景 + +图片画布编辑器已有普通图片生成与“生成规范”能力。本次新增“生成角色形象”入口,用于在同一画布内生成标注为“角色”的单张角色形象图片,并支持绑定角色形象规范与常规参考图。 + +## 入口与画布表现 + +- 底部 AI 画布工具栏新增 `生成角色形象` 按钮。 +- 点击后在画布中心创建一张空白图片占位图,面板锚定在占位图下方,视觉风格复用现有生成新图片面板;底部 AI 工具栏继续保持可见,不因角色面板打开而隐藏。 +- 占位图与生成成功后的图片右上角都覆盖 `角色` 标签。 +- 角色图层在布局快照中写入 `assetKind: "character"`,刷新后仍显示 `角色` 标签。 + +## 面板结构 + +角色生成面板只包含创作必需输入;每个输入框、参考图入口和选项按钮都必须展示对应中文字段标题,不只依赖 placeholder、按钮文案或 aria-label: + +1. 第一项参考图入口为 `角色形象规范`。 + - 入口必须采用 Lovart 式参考图卡片:左侧小预览 / 图标,中间短标题,右侧仅保留必要状态,不把说明性规则文案铺在 UI 上。 + - 点击后弹出菜单:`从画布中选择`、`新建角色形象规范`、`上传图片`。 + - 来源菜单通过页面级 fixed portal 渲染,层级高于角色面板与参考图横向滚动区,不能被 `.image-canvas-editor__character-reference-row` 裁切。 + - `从画布中选择` 进入画布点选状态,点击已有图片后把该图绑定为角色形象规范;按 `Esc` 退出点选状态。 + - `新建角色形象规范` 复用当前 `生成规范 -> 角色形象规范` 流程。 + - `上传图片` 使用现有本地图片上传入口。 +2. 规范入口后方是常规参考图入口。 + - `上传常规参考图` 同样使用 Lovart 式参考图卡片,不只显示一段文字按钮。 + - 上传后的每张常规参考图以缩略图展示。 + - 每张常规参考图右下角显示大号序号,从 `1` 开始递增。 +3. 唯一文本框为 `角色设定`。 +4. 左下角展示画面比例和大小选择按钮。 +5. 右下角展示模型选择和生成按钮。 + +## 生成与参考图契约 + +- 前端提交角色生成时,使用 `POST /api/editor/images/generations`。 +- `kind` 传 `character`,用于后端日志 / 审计语义识别。 +- 角色形象规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为: + 1. 角色形象规范图。 + 2. 常规参考图列表。 +- 当前请求尺寸沿用编辑器普通生成默认值;比例和大小按钮先复用现有占位交互。 +- 后端如果收到参考图,则走带多参考图的图片编辑/参考图生成链路;没有参考图时走纯文本生成链路。 +- `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架: + +```text +基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。 +角色设定:<用户输入的角色设定> +``` + +- 角色图生成完成后,后端必须先对返回图片执行绿幕 / 近白背景去背,并统一输出透明背景 PNG;随后写入 OSS 私有对象,并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。 + +## 可访问性与状态 + +- 点选状态下画布显示状态提示 `请选择画布中的图片作为角色形象规范,按 Esc 退出`。 +- 已绑定的角色形象规范入口显示所选图片标题。 +- 生成中禁用参考图入口、文本框和按钮。 +- 点击画布空白区域让当前占位图或图片失焦时,关闭角色生成面板并移除图片选中样式;占位图本身保持可重新打开。删除图层逻辑沿用现有图层删除能力。 + +## 验收 + +- 点击 `生成角色形象` 后出现角色占位图、角色标签和角色生成面板。 +- 角色生成面板打开时底部 AI 工具栏仍可见;点击画布空白区域后面板关闭,当前图片不再显示选中边框。 +- `角色形象规范` 与 `上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。 +- `从画布中选择` 后点击已有画布图片可绑定为角色形象规范,`Esc` 可退出点选状态。 +- 上传常规参考图后缩略图右下角显示序号。 +- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt 和参考图数组。 +- 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。 + +## 当前落地记录 + +- 前端画板已接入 `生成角色形象` 底部入口、角色占位图、角色面板、画布点选规范图、上传规范图、上传常规参考图和序号角标。 +- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色形象规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。 +- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后自动执行绿幕去背、写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 +- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 +- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 +- 本次验证命令: + - `npm test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` + - `npm test -- src/services/image-editor/editorProjectClient.test.ts` + - `npm run typecheck -- --pretty false` + +## 角色动画生成 + +### 入口规则 + +- 仅 `assetKind: "character"` 的角色图片图层显示 `生成动画` 入口。 +- 入口同时出现在图片上方悬浮工具条和图片右键功能列表中。 +- 非角色图片不得显示 `生成动画`,也不得通过右键菜单触发角色动画生成。 +- 点击 `生成动画` 后,在角色图片右侧打开独立的 `角色动画生成面板`;面板不在当前图片下方展开。 + +### 面板字段 + +1. 第一模块为 `动画描述` 文本框,最多输入 `4000` 字。 +2. 文本框下方提供预设动作提示词按钮:`待机`、`行走`、`奔跑`、`跳跃`、`攻击`、`受击`、`倒下`。点击后把对应动作文本写入动画描述文本框。 +3. 其它设置包括: + - 分辨率:`480p`、`720p`。 + - 画面比例:默认 `与角色图片保持同尺寸`,可选 `1:1`、`4:3`、`16:9`、`9:16`、`3:4`。 + - 时长:`32帧·4秒`、`40帧·5秒`、`48帧·6秒`。 +4. 模型固定使用 `seedance2.0`,前端不提供模型切换。 +5. 生成按钮上方显示本次生成文本摘要和生成价格: + - `480p` 每秒 `10` 泥点。 + - `720p` 每秒 `20` 泥点。 + +### Prompt 与生成契约 + +- 前端提交到 `POST /api/editor/character-animations/generations`。 +- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。 +- 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 seedance2.0 对应后端模型。 +- 后端路由兼容旧 Data URL 请求并单独放宽 JSON body limit 到 `12MB`,但该限额只作为兼容兜底,不作为新链路默认传大图的方式。 +- 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后: + +```text +生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。 +动作描述: +<用户输入的动画描述> +``` + +### 抽帧与 OSS 存储 + +- 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。 +- 每帧必须执行绿幕去背,输出透明背景 PNG。 +- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 +- 画板前端首版只展示生成完成结果摘要,不把帧序列自动铺到画布上;后续若要展示逐帧图层,必须继续复用画布图层与素材库资源模型。 + diff --git a/scripts/dev.mjs b/scripts/dev.mjs index a76c20ee..1e73b8a5 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -37,7 +37,10 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml'); const modulePath = resolve(serverRsDir, 'crates/spacetime-module'); const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs'); const adminWebDir = resolve(repoRoot, 'apps/admin-web'); -const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env'; +function resolveLocalDevRustcWrapperBypass() { + // Windows 下不能把 rustc 自身当成 Cargo wrapper;空值会覆盖仓库 .cargo/config.toml 中的 sccache。 + return process.platform === 'win32' ? '' : '/usr/bin/env'; +} const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web']; const SERVICE_ALIASES = new Map([ @@ -423,8 +426,9 @@ function buildLocalRustProcessEnv(env, options = {}) { return mergedEnv; } - mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS; - mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS; + const rustcWrapperBypass = resolveLocalDevRustcWrapperBypass(); + mergedEnv.RUSTC_WRAPPER = rustcWrapperBypass; + mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = rustcWrapperBypass; if (options.log !== false) { console.warn( '[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。', diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 3cd31d33..105113c6 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -213,6 +213,31 @@ describe('dev scheduler Rust build env', () => { expect(env.RUSTC_WRAPPER).toBe('custom-wrapper'); expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper'); }); + + test('Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32', + }); + + try { + const env = buildLocalRustProcessEnv( + { + RUSTC_WRAPPER: 'sccache', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }, + {log: false}, + ); + + expect(env.RUSTC_WRAPPER).toBe(''); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(''); + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + } + }); }); describe('dev scheduler stack state file', () => { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d840369e..b70568e9 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1469,6 +1469,53 @@ mod tests { assert!(!body_text.contains("length limit exceeded")); } + #[tokio::test] + async fn editor_character_animation_accepts_character_image_body_above_default_limit() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); + let request_body = serde_json::json!({ + "sourceLayerId": "layer-character-large", + "sourceImageSrc": source_image_src, + "sourceWidth": 1024, + "sourceHeight": 1024, + "promptText": "待机呼吸循环。", + "resolution": "480p", + "ratio": "same", + "frameCount": 32, + "durationSeconds": 4, + "priceMudPoints": 40, + "model": "seedance2.0" + }) + .to_string(); + assert!(request_body.len() > 2 * 1024 * 1024); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/character-animations/generations") + .header("content-type", "application/json") + .body(Body::from(request_body)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("ARK_CHARACTER_VIDEO_BASE_URL"), + "handler should parse the oversized character source image before checking Ark config: {body_text}" + ); + assert!(!body_text.contains("length limit exceeded")); + } + #[tokio::test] async fn password_entry_rejects_unknown_phone_without_registration() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index a2a5d0c6..8740d379 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -41,6 +41,8 @@ use shared_contracts::assets::{ CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse, CharacterVisualDraftPayload, CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload, CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse, + EditorCharacterAnimationFramePayload, EditorCharacterAnimationGenerateRequest, + EditorCharacterAnimationGenerateResponse, }; use spacetime_client::SpacetimeClientError; @@ -82,6 +84,9 @@ const FIXED_ARK_CHARACTER_VIDEO_RESOLUTION: &str = "480p"; const FIXED_ARK_CHARACTER_VIDEO_RATIO: &str = "1:1"; const FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS: u32 = 4; const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000; +const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0"; +const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation"; +const EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX: &str = "生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:"; const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [ MotionTemplate { @@ -489,6 +494,87 @@ pub async fn generate_character_animation( )) } +pub async fn generate_editor_character_animation( + State(state): State, + Extension(request_context): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + character_animation_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-character-animation", + "message": error.body_text(), + })), + ) + })?; + + let normalized = normalize_editor_character_animation_request(payload) + .map_err(|error| character_animation_error_response(&request_context, error))?; + let settings = require_editor_character_animation_settings(&state, &normalized) + .map_err(|error| character_animation_error_response(&request_context, error))?; + let extraction_settings = resolve_backend_frame_extraction_settings(&state); + let http_client = build_upstream_http_client(settings.ark.request_timeout_ms) + .map_err(|error| character_animation_error_response(&request_context, error))?; + let owner_user_id = "editor-character-animation".to_string(); + let task_id = generate_ai_task_id(current_utc_micros()); + + let result = async { + let source_data_url = resolve_media_source_as_data_url( + &state, + &http_client, + normalized.source_image_src.as_str(), + "sourceImageSrc", + ) + .await?; + let generated = request_editor_character_animation_preview( + &state, + &http_client, + &settings, + owner_user_id.as_str(), + normalized.source_layer_id.as_str(), + task_id.as_str(), + normalized.prompt.as_str(), + source_data_url.as_str(), + ) + .await?; + let frames = extract_and_persist_editor_character_animation_frames( + &state, + owner_user_id.as_str(), + normalized.source_layer_id.as_str(), + task_id.as_str(), + generated.preview_video_path.as_str(), + &normalized, + &extraction_settings, + ) + .await?; + + Ok::<_, AppError>((generated, frames)) + } + .await; + + let (generated, frames) = result + .map_err(|error| character_animation_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + EditorCharacterAnimationGenerateResponse { + ok: true, + task_id, + model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), + prompt: generated.submitted_prompt, + preview_video_path: generated.preview_video_path, + frame_count: frames.len() as u32, + duration_seconds: normalized.duration_seconds, + frame_width: normalized.frame_width, + frame_height: normalized.frame_height, + fps: normalized.fps, + price_mud_points: normalized.price_mud_points, + frames, + }, + )) +} + pub async fn get_character_animation_job( State(state): State, Extension(request_context): Extension, @@ -1248,6 +1334,167 @@ async fn put_generated_preview_video( Ok(put_result.legacy_public_path) } +async fn request_editor_character_animation_preview( + state: &AppState, + http_client: &reqwest::Client, + settings: &EditorCharacterAnimationSettings, + owner_user_id: &str, + source_layer_id: &str, + task_id: &str, + prompt: &str, + source_frame_data_url: &str, +) -> Result { + let upstream_task_id = + create_editor_ark_image_to_video_task(http_client, settings, prompt, source_frame_data_url) + .await?; + let video_url = + wait_for_ark_content_generation_task(http_client, &settings.ark, upstream_task_id.as_str()) + .await?; + let preview_payload = + download_generated_video(http_client, video_url.as_str(), "下载画板角色动画视频失败。") + .await?; + let preview_video_path = put_generated_preview_video( + state, + owner_user_id, + source_layer_id, + "editor-character-animation", + task_id, + preview_payload, + ) + .await?; + + Ok(GeneratedAnimationPreview { + preview_video_path, + upstream_task_id, + submitted_prompt: prompt.to_string(), + moderation_fallback_applied: false, + }) +} + +async fn create_editor_ark_image_to_video_task( + http_client: &reqwest::Client, + settings: &EditorCharacterAnimationSettings, + prompt: &str, + source_frame_data_url: &str, +) -> Result { + let response = http_client + .post(format!("{}/contents/generations/tasks", settings.ark.base_url)) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.ark.api_key), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&json!({ + "model": settings.ark.model, + "content": [ + { + "type": "text", + "text": prompt, + }, + { + "type": "image_url", + "image_url": { + "url": source_frame_data_url, + }, + "role": "first_frame", + }, + { + "type": "image_url", + "image_url": { + "url": source_frame_data_url, + }, + "role": "last_frame", + } + ], + "resolution": settings.resolution, + "ratio": settings.ratio, + "duration": settings.duration_seconds, + "watermark": false, + })) + .send() + .await + .map_err(|error| { + map_character_animation_upstream_error(format!("请求 Ark 视频服务失败:{error}")) + })?; + + let status = response.status(); + let body = response.text().await.map_err(|error| { + map_character_animation_upstream_error(format!("读取 Ark 视频任务响应失败:{error}")) + })?; + if !status.is_success() { + return Err(parse_animation_upstream_error( + body.as_str(), + "创建画板角色动画视频任务失败。", + )); + } + let payload = parse_animation_json_payload(body.as_str(), "创建画板角色动画视频任务失败。")?; + extract_animation_task_id(&payload.payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "ark", + "message": "画板角色动画视频任务未返回任务 id。", + })) + }) +} + +async fn extract_and_persist_editor_character_animation_frames( + state: &AppState, + owner_user_id: &str, + source_layer_id: &str, + task_id: &str, + preview_video_path: &str, + request: &NormalizedEditorCharacterAnimationRequest, + extraction_settings: &BackendFrameExtractionSettings, +) -> Result, AppError> { + let plan = AnimationFrameExtractionPlan { + frame_count: request.frame_count, + apply_chroma_key: true, + sample_start_ratio: 0.0, + sample_end_ratio: 1.0, + }; + let finalized_frames = extract_animation_frames_from_preview_video( + state, + preview_video_path, + request.frame_width, + request.frame_height, + extraction_settings, + &plan, + ) + .await?; + + let mut frame_payloads = Vec::with_capacity(finalized_frames.len()); + for (index, frame) in finalized_frames.into_iter().enumerate() { + let put_result = put_character_animation_object( + state, + LegacyAssetPrefix::Animations, + vec![ + "editor".to_string(), + sanitize_storage_segment(source_layer_id, "layer"), + task_id.to_string(), + ], + format!("frame{:02}.{}", index + 1, frame.extension), + frame.mime_type, + frame.bytes, + build_asset_metadata( + EDITOR_CHARACTER_ANIMATION_ASSET_KIND, + owner_user_id, + "editor_layer", + source_layer_id, + "animation_frame", + "editor-character-animation", + ), + ) + .await?; + frame_payloads.push(EditorCharacterAnimationFramePayload { + frame_index: index as u32 + 1, + image_src: put_result.legacy_public_path, + width: request.frame_width, + height: request.frame_height, + }); + } + + Ok(frame_payloads) +} + async fn publish_animation_set( state: &AppState, owner_user_id: &str, @@ -1864,6 +2111,235 @@ fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest normalize_required_text(candidate, CHARACTER_ANIMATION_MODEL) } +fn normalize_editor_character_animation_request( + payload: EditorCharacterAnimationGenerateRequest, +) -> Result { + let source_layer_id = normalize_required_text(payload.source_layer_id.as_str(), ""); + if source_layer_id.is_empty() { + return Err(editor_character_animation_bad_request( + "sourceLayerId 不能为空。", + )); + } + let source_image_src = trim_optional_text(Some(payload.source_image_src.as_str())) + .ok_or_else(|| editor_character_animation_bad_request("sourceImageSrc 不能为空。"))?; + let prompt_text = payload.prompt_text.trim().chars().take(4000).collect::(); + if prompt_text.is_empty() { + return Err(editor_character_animation_bad_request("动画描述不能为空。")); + } + let resolution = normalize_editor_character_animation_resolution(payload.resolution.as_str())?; + let ratio = normalize_editor_character_animation_ratio(payload.ratio.as_str())?; + let frame_count = normalize_editor_character_animation_frame_count(payload.frame_count)?; + let duration_seconds = + normalize_editor_character_animation_duration(payload.duration_seconds, frame_count)?; + let expected_price = calculate_editor_character_animation_price(resolution, duration_seconds); + if payload.price_mud_points != expected_price { + return Err(editor_character_animation_bad_request(format!( + "priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。" + ))); + } + if payload.model.trim() != EDITOR_CHARACTER_ANIMATION_MODEL { + return Err(editor_character_animation_bad_request( + "model 必须固定为 seedance2.0。", + )); + } + let (frame_width, frame_height) = resolve_editor_character_animation_frame_size( + payload.source_width, + payload.source_height, + ratio, + resolution, + ); + let prompt = build_editor_character_animation_prompt(prompt_text.as_str()); + + Ok(NormalizedEditorCharacterAnimationRequest { + source_layer_id, + source_image_src, + prompt, + resolution: resolution.to_string(), + ratio: resolve_editor_character_animation_provider_ratio( + ratio, + payload.source_width, + payload.source_height, + ), + frame_count, + duration_seconds, + price_mud_points: expected_price, + frame_width, + frame_height, + fps: (frame_count / duration_seconds).max(1), + }) +} + +fn require_editor_character_animation_settings( + state: &AppState, + request: &NormalizedEditorCharacterAnimationRequest, +) -> Result { + let base_url = state + .config + .ark_character_video_base_url + .trim() + .trim_end_matches('/'); + if base_url.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "ark", + "reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置", + })), + ); + } + let api_key = state + .config + .ark_character_video_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "ark", + "reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置", + })) + })?; + // 中文注释:画板角色动画入口产品侧固定为 seedance2.0,不继承通用角色视频环境模型覆盖。 + Ok(EditorCharacterAnimationSettings { + ark: ArkVideoSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.ark_character_video_request_timeout_ms.max(1), + model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), + }, + resolution: request.resolution.clone(), + ratio: request.ratio.clone(), + duration_seconds: request.duration_seconds, + }) +} + +fn build_editor_character_animation_prompt(prompt_text: &str) -> String { + format!( + "{}\n{}", + EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX, + prompt_text.trim() + ) +} + +fn normalize_editor_character_animation_resolution(value: &str) -> Result<&'static str, AppError> { + match value.trim() { + "480p" => Ok("480p"), + "720p" => Ok("720p"), + _ => Err(editor_character_animation_bad_request( + "resolution 只支持 480p 或 720p。", + )), + } +} + +fn normalize_editor_character_animation_ratio(value: &str) -> Result<&'static str, AppError> { + match value.trim() { + "same" => Ok("same"), + "1:1" => Ok("1:1"), + "4:3" => Ok("4:3"), + "16:9" => Ok("16:9"), + "9:16" => Ok("9:16"), + "3:4" => Ok("3:4"), + _ => Err(editor_character_animation_bad_request( + "ratio 只支持 same、1:1、4:3、16:9、9:16、3:4。", + )), + } +} + +fn normalize_editor_character_animation_frame_count(value: u32) -> Result { + match value { + 32 | 40 | 48 => Ok(value), + _ => Err(editor_character_animation_bad_request( + "frameCount 只支持 32、40、48。", + )), + } +} + +fn normalize_editor_character_animation_duration( + value: u32, + frame_count: u32, +) -> Result { + let expected = match frame_count { + 32 => 4, + 40 => 5, + 48 => 6, + _ => 0, + }; + if value == expected { + Ok(value) + } else { + Err(editor_character_animation_bad_request( + "durationSeconds 必须与帧数组合为 32/4、40/5 或 48/6。", + )) + } +} + +fn calculate_editor_character_animation_price(resolution: &str, duration_seconds: u32) -> u32 { + let per_second = if resolution == "720p" { 20 } else { 10 }; + per_second * duration_seconds +} + +fn resolve_editor_character_animation_provider_ratio( + ratio: &str, + source_width: u32, + source_height: u32, +) -> String { + if ratio != "same" { + return ratio.to_string(); + } + if source_width == 0 || source_height == 0 { + return "1:1".to_string(); + } + let normalized = source_width as f32 / source_height as f32; + [ + ("1:1", 1.0f32), + ("4:3", 4.0 / 3.0), + ("16:9", 16.0 / 9.0), + ("9:16", 9.0 / 16.0), + ("3:4", 3.0 / 4.0), + ] + .into_iter() + .min_by(|(_, left), (_, right)| { + (normalized - *left) + .abs() + .partial_cmp(&(normalized - *right).abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(value, _)| value.to_string()) + .unwrap_or_else(|| "1:1".to_string()) +} + +fn resolve_editor_character_animation_frame_size( + source_width: u32, + source_height: u32, + ratio: &str, + resolution: &str, +) -> (u32, u32) { + let long_edge = if resolution == "720p" { 720 } else { 480 }; + let (ratio_width, ratio_height) = match ratio { + "same" if source_width > 0 && source_height > 0 => (source_width, source_height), + "4:3" => (4, 3), + "16:9" => (16, 9), + "9:16" => (9, 16), + "3:4" => (3, 4), + _ => (1, 1), + }; + if ratio_width >= ratio_height { + let height = ((long_edge as f32 * ratio_height as f32 / ratio_width as f32).round() as u32) + .max(1); + (long_edge, height) + } else { + let width = ((long_edge as f32 * ratio_width as f32 / ratio_height as f32).round() as u32) + .max(1); + (width, long_edge) + } +} + +fn editor_character_animation_bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-character-animation", + "message": message.into(), + })) +} + fn build_animation_generate_result_payload(generated: &CharacterAnimationGeneratedDraft) -> Value { match generated.preview_video_path.as_ref() { Some(preview_video_path) => json!({ @@ -3353,6 +3829,28 @@ struct ArkVideoSettings { model: String, } +struct EditorCharacterAnimationSettings { + ark: ArkVideoSettings, + resolution: String, + ratio: String, + duration_seconds: u32, +} + +#[derive(Debug)] +struct NormalizedEditorCharacterAnimationRequest { + source_layer_id: String, + source_image_src: String, + prompt: String, + resolution: String, + ratio: String, + frame_count: u32, + duration_seconds: u32, + price_mud_points: u32, + frame_width: u32, + frame_height: u32, + fps: u32, +} + struct GeneratedAnimationPreview { preview_video_path: String, upstream_task_id: String, @@ -3548,4 +4046,73 @@ mod tests { assert_eq!(resolve_character_animation_model(&payload), "wan-move"); } + + #[test] + fn editor_character_animation_normalizes_seedance_request_contract() { + let normalized = + normalize_editor_character_animation_request(EditorCharacterAnimationGenerateRequest { + source_layer_id: " layer-hero ".to_string(), + source_image_src: "/generated-characters/hero/master.png".to_string(), + source_width: 768, + source_height: 1024, + prompt_text: "待机呼吸,轻微摆动。".to_string(), + resolution: "720p".to_string(), + ratio: "same".to_string(), + frame_count: 48, + duration_seconds: 6, + price_mud_points: 120, + model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), + }) + .expect("editor request should normalize"); + + assert_eq!(normalized.source_layer_id, "layer-hero"); + assert_eq!(normalized.frame_count, 48); + assert_eq!(normalized.duration_seconds, 6); + assert_eq!(normalized.price_mud_points, 120); + assert_eq!(normalized.fps, 8); + assert_eq!(normalized.frame_width, 540); + assert_eq!(normalized.frame_height, 720); + assert_eq!(normalized.ratio, "3:4"); + } + + #[test] + fn editor_character_animation_rejects_invalid_frame_duration_pair() { + let error = + normalize_editor_character_animation_request(EditorCharacterAnimationGenerateRequest { + source_layer_id: "layer-hero".to_string(), + source_image_src: "/generated-characters/hero/master.png".to_string(), + source_width: 1024, + source_height: 1024, + prompt_text: "奔跑".to_string(), + resolution: "480p".to_string(), + ratio: "1:1".to_string(), + frame_count: 48, + duration_seconds: 4, + price_mud_points: 40, + model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), + }) + .expect_err("invalid frame/duration pair should fail"); + + assert!( + error + .body_text() + .contains("durationSeconds 必须与帧数组合") + ); + } + + #[test] + fn editor_character_animation_builds_required_green_screen_prompt() { + let prompt = build_editor_character_animation_prompt("行走两步后回到站姿。"); + + assert!(prompt.contains("生成游戏角色动画")); + assert!(prompt.contains("参考图作为首帧和尾帧")); + assert!(prompt.contains("背景固定为纯绿色绿幕")); + assert!(prompt.contains("动作描述:\n行走两步后回到站姿。")); + } + + #[test] + fn editor_character_animation_price_depends_on_resolution_and_duration() { + assert_eq!(calculate_editor_character_animation_price("480p", 4), 40); + assert_eq!(calculate_editor_character_animation_price("720p", 6), 120); + } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 34065c8c..5e002c9a 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1,18 +1,31 @@ +use std::{borrow::Cow, collections::BTreeMap}; + use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use module_assets::{ + AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input, + generate_asset_object_id, +}; +use platform_image::{ + DownloadedImage, + generated_asset_sheets::{ + GeneratedAssetSheetConnectedIcon, slice_generated_icon_spritesheet_by_connected_components, + }, +}; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, - EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, - EditorAssetLibraryRecord, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, - EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, - EditorProjectGetRecordInput, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, + EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetRecord, + EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, + EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError, }; @@ -20,12 +33,19 @@ use spacetime_client::{ use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, + generated_image_assets::{ + GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, + adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, + normalize_generated_image_asset_mime, + }, http_error::AppError, openai_image_generation::{ - GPT_IMAGE_2_MODEL, OpenAiReferenceImage, build_openai_image_http_client, - create_openai_image_edit_with_references, create_openai_image_generation, + DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage, + build_openai_image_http_client, create_openai_image_edit_with_references, + create_openai_image_edit_with_references_and_model, create_openai_image_generation, require_openai_image_settings, }, + platform_errors::map_oss_error, request_context::RequestContext, state::AppState, }; @@ -37,6 +57,13 @@ const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-"; const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024; const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024"; +const EDITOR_ICON_SPRITESHEET_MODEL: &str = "gemini-3.1-flash-image-preview"; +const EDITOR_ICON_SPRITESHEET_SMALL_SIZE: &str = "512x512"; +const EDITOR_ICON_SPRITESHEET_LARGE_SIZE: &str = "1024x1024"; +const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; +const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; +const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; +const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -125,6 +152,10 @@ pub struct EditorAssetUpdateRequest { #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationRequest { prompt: String, + size: Option, + kind: Option, + model: Option, + reference_image_srcs: Option>, } #[derive(Debug, Deserialize)] @@ -134,6 +165,14 @@ pub struct EditorImageEditRequest { source_image_src: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetGenerationRequest { + reference_image_src: String, + icon_descriptions: Vec, + model: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResponse { @@ -192,6 +231,8 @@ pub struct EditorAssetResponse { #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationResponse { image_src: String, + object_key: Option, + asset_object_id: Option, width: u32, height: u32, source_type: &'static str, @@ -202,6 +243,29 @@ pub struct EditorImageGenerationResponse { task_id: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetIconResponse { + name: String, + image_src: String, + width: u32, + height: u32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetGenerationResponse { + spritesheet_image_src: String, + spritesheet_width: u32, + spritesheet_height: u32, + icon_image_srcs: Vec, + prompt: String, + actual_prompt: Option, + model: String, + provider: &'static str, + task_id: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectPayload { @@ -683,8 +747,8 @@ pub async fn generate_editor_image( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { - let prompt = payload.prompt.trim().to_string(); - if prompt.is_empty() { + let role_setting = payload.prompt.trim().to_string(); + if role_setting.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-generation", @@ -693,33 +757,93 @@ pub async fn generate_editor_image( ); } + let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); + let _requested_model = payload.model.as_deref(); + let normalized_kind = payload.kind.as_deref().map(str::trim); + let is_character_generation = matches!(normalized_kind, Some("character")); + let submitted_prompt = if is_character_generation { + build_editor_character_image_prompt(role_setting.as_str()) + } else { + role_setting.clone() + }; + let failure_context = match normalized_kind { + Some("character") => "图片画布生成角色形象", + Some("spec") => "图片画布生成规范", + Some("quick-edit") => "图片画布快速编辑图片", + _ => "图片画布生成图片", + }; + let reference_sources = payload + .reference_image_srcs + .unwrap_or_default() + .into_iter() + .map(|source| source.trim().to_string()) + .filter(|source| !source.is_empty()) + .take(5) + .collect::>(); let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( &request_context, Some(authenticated.claims().user_id().to_string()), None, ); let http_client = build_openai_image_http_client(&settings)?; - let generated = create_openai_image_generation( - &http_client, - &settings, - prompt.as_str(), - Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"), - EDITOR_IMAGE_GENERATION_SIZE, - 1, - &[], - "图片画布生成图片", - ) - .await?; - let image = generated.images.into_iter().next().ok_or_else(|| { + let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"); + let generated = if reference_sources.is_empty() { + create_openai_image_generation( + &http_client, + &settings, + submitted_prompt.as_str(), + negative_prompt, + image_size.as_ref(), + 1, + &[], + failure_context, + ) + .await? + } else { + let reference_images = reference_sources + .iter() + .map(|source| parse_editor_reference_image(source.as_str())) + .collect::, _>>()?; + create_openai_image_edit_with_references( + &http_client, + &settings, + submitted_prompt.as_str(), + negative_prompt, + image_size.as_ref(), + 1, + reference_images.as_slice(), + failure_context, + ) + .await? + }; + let mut image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "VectorEngine 未返回图片", })) })?; + if is_character_generation { + image = prepare_editor_character_image_for_response(image); + } let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); + let persisted = if is_character_generation { + Some( + persist_editor_character_image( + &state, + authenticated.claims().user_id(), + generated.task_id.as_str(), + &image, + submitted_prompt.as_str(), + generated.actual_prompt.as_deref(), + ) + .await?, + ) + } else { + None + }; let image_src = format!( "data:{};base64,{}", image.mime_type, @@ -730,10 +854,14 @@ pub async fn generate_editor_image( Some(&request_context), EditorImageGenerationResponse { image_src, + object_key: persisted.as_ref().map(|asset| asset.object_key.clone()), + asset_object_id: persisted + .as_ref() + .map(|asset| asset.asset_object_id.clone()), width, height, source_type: "generated", - prompt, + prompt: role_setting, actual_prompt: generated.actual_prompt, model: GPT_IMAGE_2_MODEL, provider: "VectorEngine", @@ -742,6 +870,31 @@ pub async fn generate_editor_image( )) } +fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> { + match size.map(str::trim).filter(|value| !value.is_empty()) { + Some("1024x1024") | Some("1024*1024") | Some("1:1") => Cow::Borrowed("1024x1024"), + Some("1536x1024") | Some("1536*1024") | Some("16:9") => Cow::Borrowed("1536x1024"), + Some("2048x1152") | Some("2048*1152") | Some("1920x1080") | Some("1920*1080") + | Some("2k-16:9") => Cow::Borrowed("2048x1152"), + Some("1024x1536") | Some("1024*1536") | Some("9:16") => Cow::Borrowed("1024x1536"), + Some(value) if is_editor_custom_image_size(value) => Cow::Owned(value.to_string()), + _ => Cow::Borrowed(EDITOR_IMAGE_GENERATION_SIZE), + } +} + +fn is_editor_custom_image_size(value: &str) -> bool { + let Some((width, height)) = value.split_once('x') else { + return false; + }; + let Ok(width) = width.parse::() else { + return false; + }; + let Ok(height) = height.parse::() else { + return false; + }; + (64..=4096).contains(&width) && (64..=4096).contains(&height) +} + pub async fn edit_editor_image( State(state): State, Extension(request_context): Extension, @@ -795,6 +948,8 @@ pub async fn edit_editor_image( Some(&request_context), EditorImageGenerationResponse { image_src, + object_key: None, + asset_object_id: None, width, height, source_type: "generated", @@ -807,6 +962,96 @@ pub async fn edit_editor_image( )) } +pub async fn generate_editor_icon_spritesheet( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; + let reference_image = parse_editor_reference_image(payload.reference_image_src.as_str()) + .map_err(|error| { + error.with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "referenceImageSrc", + "message": "图标素材规范必须是图片 Data URL。", + })) + })?; + let model = payload + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(EDITOR_ICON_SPRITESHEET_MODEL) + .to_string(); + let size = editor_icon_spritesheet_size_for_count(icon_descriptions.len()); + let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions); + + let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( + &request_context, + Some(authenticated.claims().user_id().to_string()), + None, + ); + let http_client = build_openai_image_http_client(&settings)?; + let generated = create_openai_image_edit_with_references_and_model( + &http_client, + &settings, + model.as_str(), + prompt.as_str(), + None, + size, + 1, + &[reference_image], + "图片画布生成图标素材 spritesheet", + ) + .await?; + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "VectorEngine 未返回图标 spritesheet", + })) + })?; + let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or((512, 512)); + let source = DownloadedImage { + bytes: image.bytes.clone(), + mime_type: image.mime_type.clone(), + extension: image.extension.clone(), + }; + let icon_slices = slice_generated_icon_spritesheet_by_connected_components( + &source, + icon_descriptions.as_slice(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-icon-spritesheet", + "message": error.to_string(), + })) + })?; + let spritesheet_image_src = + data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice()); + let icon_image_srcs = icon_slices + .into_iter() + .map(editor_icon_response_from_slice) + .collect(); + + Ok(json_success_body( + Some(&request_context), + EditorIconSpritesheetGenerationResponse { + spritesheet_image_src, + spritesheet_width, + spritesheet_height, + icon_image_srcs, + prompt, + actual_prompt: generated.actual_prompt, + model, + provider: "VectorEngine", + task_id: generated.task_id, + }, + )) +} + fn editor_project_payload_from_record(record: EditorProjectRecord) -> EditorProjectPayload { let canvas = editor_canvas_payload_from_record(record.canvas); EditorProjectPayload { @@ -957,6 +1202,229 @@ fn normalize_optional_string(value: Option) -> Option { .filter(|item| !item.is_empty()) } +fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String { + let normalized = value + .trim() + .chars() + .map(|character| match character { + 'a'..='z' | '0'..='9' | '-' | '_' => character, + 'A'..='Z' => character.to_ascii_lowercase(), + _ => '-', + }) + .collect::() + .split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-"); + if normalized.is_empty() { + fallback.to_string() + } else { + normalized + } +} + +fn normalize_icon_descriptions(descriptions: Vec) -> Result, AppError> { + let normalized = descriptions + .into_iter() + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()) + .take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1)) + .collect::>(); + if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "iconDescriptions", + "message": "图标素材描述数量必须在 1 到 100 个之间。", + })), + ); + } + Ok(normalized) +} + +fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String { + format!( + "参考图1的图标素材规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}", + icon_descriptions.join("、") + ) +} + +fn build_editor_character_image_prompt(role_setting: &str) -> String { + vec![ + "严格基于图1的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), + format!("角色设定:{}", role_setting.trim()), + ] + .join("\n") +} + +fn prepare_editor_character_image_for_response( + image: DownloadedOpenAiImage, +) -> DownloadedOpenAiImage { + let source_bytes = if image.mime_type == "image/png" { + image.bytes + } else { + match image::load_from_memory(image.bytes.as_slice()) { + Ok(decoded) => { + let mut encoded = std::io::Cursor::new(Vec::new()); + if decoded + .write_to(&mut encoded, image::ImageFormat::Png) + .is_err() + { + return image; + } + encoded.into_inner() + } + Err(_) => return image, + } + }; + + let processed = + crate::character_visual_assets::try_apply_background_alpha_to_png(source_bytes.as_slice()); + let Some(bytes) = processed else { + return DownloadedOpenAiImage { + bytes: source_bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + }; + + DownloadedOpenAiImage { + bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + } +} + +fn editor_icon_spritesheet_size_for_count(icon_count: usize) -> &'static str { + if icon_count <= 25 { + EDITOR_ICON_SPRITESHEET_SMALL_SIZE + } else { + EDITOR_ICON_SPRITESHEET_LARGE_SIZE + } +} + +fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String { + format!( + "data:{};base64,{}", + mime_type, + BASE64_STANDARD.encode(bytes) + ) +} + +fn editor_icon_response_from_slice( + icon: GeneratedAssetSheetConnectedIcon, +) -> EditorIconSpritesheetIconResponse { + EditorIconSpritesheetIconResponse { + name: icon.name, + image_src: data_url_from_image_bytes("image/png", icon.bytes.as_slice()), + width: icon.width, + height: icon.height, + } +} + +struct PersistedEditorGeneratedImage { + object_key: String, + asset_object_id: String, +} + +async fn persist_editor_character_image( + state: &AppState, + owner_user_id: &str, + task_id: &str, + image: &DownloadedOpenAiImage, + prompt: &str, + actual_prompt: Option<&str>, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let prepared = + GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec![ + "editor".to_string(), + "character-images".to_string(), + sanitize_editor_storage_segment(task_id, "task"), + ], + file_stem: "image".to_string(), + image: GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(image.mime_type.as_str()), + bytes: image.bytes.clone(), + }, + access: OssObjectAccess::Private, + metadata: GeneratedImageAssetAdapterMetadata { + asset_kind: Some(EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string()), + owner_user_id: Some(owner_user_id.to_string()), + entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()), + entity_id: Some(task_id.to_string()), + slot: Some(EDITOR_CHARACTER_IMAGE_SLOT.to_string()), + provider: Some("vector-engine".to_string()), + task_id: Some(task_id.to_string()), + }, + extra_metadata: BTreeMap::from([( + "source".to_string(), + "image-canvas-editor".to_string(), + )]), + }) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "generated-image-assets", + "message": format!("准备画板角色形象 OSS 上传请求失败:{error:?}"), + })) + })?; + let persisted_mime_type = prepared.format.mime_type.clone(); + let http_client = reqwest::Client::new(); + let put_result = oss_client + .put_object(&http_client, prepared.request) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let head = oss_client + .head_object( + &http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let now_micros = current_utc_micros(); + let asset_object = state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + head.bucket, + head.object_key.clone(), + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(persisted_mime_type)), + head.content_length, + Some(actual_prompt.unwrap_or(prompt).to_string()), + EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + None, + Some(task_id.to_string()), + now_micros, + ) + .map_err(map_editor_asset_field_error)?, + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + })?; + + Ok(PersistedEditorGeneratedImage { + object_key: head.object_key, + asset_object_id: asset_object.asset_object_id, + }) +} + fn parse_editor_reference_image(source: &str) -> Result { let Some((header, data)) = source.trim().split_once(',') else { return Err( @@ -1023,12 +1491,11 @@ fn map_editor_project_error(error: SpacetimeClientError) -> AppError { "message": message, })) } - SpacetimeClientError::Runtime(message) => { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ "provider": "editor-project", "message": message, - })) - } + })), other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": other.to_string(), @@ -1036,6 +1503,13 @@ fn map_editor_project_error(error: SpacetimeClientError) -> AppError { } } +fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "message": error.to_string(), + })) +} + fn current_utc_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; @@ -1044,3 +1518,136 @@ fn current_utc_micros() -> i64 { .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() { + assert_eq!(normalize_editor_image_generation_size(None), "1024x1024"); + assert_eq!( + normalize_editor_image_generation_size(Some("1536x1024")), + "1536x1024" + ); + assert_eq!( + normalize_editor_image_generation_size(Some("1024x1536")), + "1024x1536" + ); + assert_eq!( + normalize_editor_image_generation_size(Some("2048x1152")), + "2048x1152" + ); + assert_eq!( + normalize_editor_image_generation_size(Some("640x640")), + "640x640" + ); + assert_eq!( + normalize_editor_image_generation_size(Some("bad-size")), + "1024x1024" + ); + } + + #[test] + fn editor_character_image_prompt_appends_user_role_setting() { + let prompt = build_editor_character_image_prompt("菜市场卖菜大妈"); + + assert!(prompt.contains("基于图1的角色美术视觉规范指导生成游戏角色形象图。")); + assert!(prompt.contains("背景固定为纯绿色绿幕")); + assert!(prompt.contains("禁止镜头透视")); + assert!(prompt.contains("角色设定:菜市场卖菜大妈")); + } + + #[test] + fn editor_character_image_postprocess_removes_green_screen_background() { + let width = 12; + let height = 12; + let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 4..8 { + for x in 4..8 { + image.put_pixel(x, y, image::Rgba([188, 82, 45, 255])); + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, image::ImageFormat::Png) + .expect("test image should encode"); + + let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed image should decode") + .to_rgba8(); + + assert_eq!(processed.mime_type, "image/png"); + assert_eq!(processed.extension, "png"); + assert_eq!(decoded.get_pixel(0, 0).0[3], 0); + assert_eq!(decoded.get_pixel(5, 5).0[3], 255); + } + + #[test] + fn editor_character_image_postprocess_converts_non_png_green_screen_to_transparent_png() { + let width = 12; + let height = 12; + let mut image = + image::RgbImage::from_pixel(width, height, image::Rgb([0_u8, 255_u8, 0_u8])); + for y in 4..8 { + for x in 4..8 { + image.put_pixel(x, y, image::Rgb([188, 82, 45])); + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(image) + .write_to(&mut encoded, image::ImageFormat::Jpeg) + .expect("test image should encode"); + + let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/jpeg".to_string(), + extension: "jpg".to_string(), + }); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed image should decode") + .to_rgba8(); + + assert_eq!(processed.mime_type, "image/png"); + assert_eq!(processed.extension, "png"); + assert_eq!(decoded.get_pixel(0, 0).0[3], 0); + assert_eq!(decoded.get_pixel(5, 5).0[3], 255); + } + + #[test] + fn editor_icon_spritesheet_prompt_uses_ordered_descriptions_and_size_tiers() { + let descriptions = vec![ + "返回按钮".to_string(), + "设置按钮".to_string(), + "下一关按钮".to_string(), + ]; + let prompt = build_editor_icon_spritesheet_prompt(&descriptions); + + assert!(prompt.contains("参考图1的图标素材规范")); + assert!(prompt.contains("纯绿幕背景方便扣除背景")); + assert!(prompt.contains("返回按钮、设置按钮、下一关按钮")); + assert_eq!(editor_icon_spritesheet_size_for_count(25), "512x512"); + assert_eq!(editor_icon_spritesheet_size_for_count(26), "1024x1024"); + } + + #[test] + fn editor_icon_description_validation_filters_empty_and_rejects_more_than_limit() { + let descriptions = normalize_icon_descriptions(vec![ + " 返回按钮 ".to_string(), + " ".to_string(), + "设置按钮".to_string(), + ]) + .expect("valid descriptions should pass"); + assert_eq!(descriptions, vec!["返回按钮", "设置按钮"]); + + let too_many = (0..101) + .map(|index| format!("图标{index}")) + .collect::>(); + assert!(normalize_icon_descriptions(too_many).is_err()); + } +} diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index ec90016a..725f8e84 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -744,6 +744,7 @@ mod tests { started_at: Some("2026-06-03T00:00:00Z".to_string()), completed_at: None, updated_at: "2026-06-03T00:00:00Z".to_string(), + updated_at_micros: 1_780_000_000_000_000, lease_token: lease_token.map(ToOwned::to_owned), } } diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 76f6e3d8..dd23407f 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -9,9 +9,9 @@ use crate::{ create_editor_asset, create_editor_asset_folder, create_editor_project, create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder, delete_editor_project, edit_editor_image, generate_editor_image, - get_editor_asset_library, get_editor_project, list_editor_projects, - load_recent_editor_project, rename_editor_project, save_editor_project_layout, - update_editor_asset, update_editor_asset_folder, + generate_editor_icon_spritesheet, get_editor_asset_library, get_editor_project, + list_editor_projects, load_recent_editor_project, rename_editor_project, + save_editor_project_layout, update_editor_asset, update_editor_asset_folder, }, state::AppState, }; @@ -111,4 +111,11 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/editor/icon-spritesheets/generations", + post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) } diff --git a/server-rs/crates/api-server/src/modules/play_flow.rs b/server-rs/crates/api-server/src/modules/play_flow.rs index be85f260..bc800e29 100644 --- a/server-rs/crates/api-server/src/modules/play_flow.rs +++ b/server-rs/crates/api-server/src/modules/play_flow.rs @@ -16,8 +16,9 @@ use crate::{ assets::get_asset_history, auth::require_bearer_auth, character_animation_assets::{ - generate_character_animation, get_character_animation_job, get_character_workflow_cache, - import_character_animation_video, list_character_animation_templates, + generate_character_animation, generate_editor_character_animation, + get_character_animation_job, get_character_workflow_cache, import_character_animation_video, + list_character_animation_templates, publish_character_animation, put_role_asset_workflow, resolve_role_asset_workflow, save_character_workflow_cache, }, @@ -48,6 +49,7 @@ use crate::{ }; const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024; +const EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PlayFlowDomainAdapter { @@ -452,6 +454,13 @@ fn play_flow_support_router(state: AppState) -> Router { "/api/assets/character-animation/generate", post(generate_character_animation), ) + .route( + "/api/editor/character-animations/generations", + post(generate_editor_character_animation).layer(DefaultBodyLimit::max( + // 中文注释:画板角色动画首版仍兼容角色图 Data URL 入参,避免大于 Axum 默认 2MB 的角色图在 handler 前被拦截。 + EDITOR_CHARACTER_ANIMATION_BODY_LIMIT_BYTES, + )), + ) .route( "/api/assets/character-animation/jobs/{task_id}", get(get_character_animation_job), diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index ea026cf6..987896fb 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -3,7 +3,7 @@ use platform_image::{ DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, - create_vector_engine_image_generation, + create_vector_engine_image_edit_with_references_and_model, create_vector_engine_image_generation, }; #[cfg(test)] use platform_image::{ @@ -236,6 +236,49 @@ pub(crate) async fn create_openai_image_edit_with_references( .await } +#[allow(clippy::too_many_arguments)] +pub(crate) async fn create_openai_image_edit_with_references_and_model( + http_client: &reqwest::Client, + settings: &OpenAiImageSettings, + model: &str, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[OpenAiReferenceImage], + failure_context: &str, +) -> Result { + let started_at_micros = current_utc_micros(); + let request_payload = json!({ + "model": model, + "size": size, + "promptChars": prompt.chars().count(), + "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), + "referenceImageCount": reference_images.len(), + }); + let result = create_vector_engine_image_edit_with_references_and_model( + http_client, + &settings.provider_settings(), + model, + prompt, + negative_prompt, + size, + candidate_count, + reference_images, + failure_context, + ) + .await; + map_platform_image_result( + settings, + result, + "image_edit_with_references", + failure_context, + request_payload, + started_at_micros, + ) + .await +} + #[cfg(test)] pub(crate) fn build_openai_image_request_body( prompt: &str, diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs index fa55105e..a9be573d 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs @@ -16,7 +16,9 @@ pub use persist::{ }; pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt}; pub use sheet::{ - GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte, + GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetSliceImage, + crop_generated_asset_sheet_view_edge_matte, crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row, + slice_generated_icon_spritesheet_by_connected_components, }; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index 740f4f43..03526681 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -132,6 +132,25 @@ pub fn slice_generated_asset_sheet_two_items_per_row( Ok(slices) } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GeneratedAssetSheetConnectedIcon { + pub name: String, + pub bytes: Vec, + pub width: u32, + pub height: u32, +} + +pub fn slice_generated_icon_spritesheet_by_connected_components( + image: &crate::DownloadedImage, + icon_names: &[String], +) -> Result, GeneratedAssetSheetError> { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}")) + })?; + let source = apply_generated_asset_sheet_green_screen_alpha(source); + slice_generated_icon_spritesheet_rgba_by_connected_components(source, icon_names) +} + pub fn crop_generated_asset_sheet_view_edge_matte( image: image::DynamicImage, ) -> image::DynamicImage { @@ -141,6 +160,207 @@ pub fn crop_generated_asset_sheet_view_edge_matte( ) } +fn slice_generated_icon_spritesheet_rgba_by_connected_components( + source: image::DynamicImage, + icon_names: &[String], +) -> Result, GeneratedAssetSheetError> { + let image = source.to_rgba8(); + let (width, height) = image.dimensions(); + let pixel_count = (width as usize).saturating_mul(height as usize); + if pixel_count == 0 { + return Err(GeneratedAssetSheetError::invalid_request( + "图标 spritesheet 尺寸为空。", + )); + } + + let mut visited = vec![false; pixel_count]; + let mut components = Vec::::new(); + for y in 0..height { + for x in 0..width { + let pixel_index = (y as usize) + .saturating_mul(width as usize) + .saturating_add(x as usize); + if visited[pixel_index] || image.get_pixel(x, y).0[3] == 0 { + continue; + } + components.push(flood_fill_generated_icon_component( + &image, + &mut visited, + width, + height, + x, + y, + )); + } + } + + components.sort_by_key(|bounds| (bounds.y0, bounds.x0)); + if components.len() < icon_names.len() { + return Err(GeneratedAssetSheetError::invalid_request(format!( + "图标 spritesheet 连通域数量不足:需要 {} 个,实际 {} 个。", + icon_names.len(), + components.len() + ))); + } + + let mut icons = Vec::with_capacity(icon_names.len()); + for (name, bounds) in icon_names.iter().zip(components.into_iter()) { + let pad_x = (bounds.width() / 12).clamp(4, 16); + let pad_y = (bounds.height() / 12).clamp(4, 16); + let crop = GeneratedAssetSheetCellBounds { + x0: bounds.x0.saturating_sub(pad_x), + y0: bounds.y0.saturating_sub(pad_y), + x1: bounds.x1.saturating_add(pad_x).min(width), + y1: bounds.y1.saturating_add(pad_y).min(height), + }; + let cropped = image::imageops::crop_imm( + &image, + crop.x0, + crop.y0, + crop.width(), + crop.height(), + ) + .to_image(); + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(cropped) + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|error| { + GeneratedAssetSheetError::encode_image(format!( + "图标 spritesheet 切割失败:{error}" + )) + })?; + icons.push(GeneratedAssetSheetConnectedIcon { + name: name.clone(), + bytes: cursor.into_inner(), + width: crop.width(), + height: crop.height(), + }); + } + + Ok(icons) +} + +fn flood_fill_generated_icon_component( + image: &image::RgbaImage, + visited: &mut [bool], + width: u32, + height: u32, + start_x: u32, + start_y: u32, +) -> GeneratedAssetSheetCellBounds { + let mut queue = vec![(start_x, start_y)]; + let mut queue_index = 0usize; + let start_index = (start_y as usize) + .saturating_mul(width as usize) + .saturating_add(start_x as usize); + visited[start_index] = true; + let mut bounds = GeneratedAssetSheetCellBounds { + x0: start_x, + y0: start_y, + x1: start_x.saturating_add(1), + y1: start_y.saturating_add(1), + }; + + while queue_index < queue.len() { + let (x, y) = queue[queue_index]; + queue_index += 1; + bounds.x0 = bounds.x0.min(x); + bounds.y0 = bounds.y0.min(y); + bounds.x1 = bounds.x1.max(x.saturating_add(1)); + bounds.y1 = bounds.y1.max(y.saturating_add(1)); + + for next_y in y.saturating_sub(1)..=(y.saturating_add(1).min(height.saturating_sub(1))) { + for next_x in x.saturating_sub(1)..=(x.saturating_add(1).min(width.saturating_sub(1))) { + if next_x == x && next_y == y { + continue; + } + let next_index = (next_y as usize) + .saturating_mul(width as usize) + .saturating_add(next_x as usize); + if visited[next_index] || image.get_pixel(next_x, next_y).0[3] == 0 { + continue; + } + visited[next_index] = true; + queue.push((next_x, next_y)); + } + } + } + + bounds +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{ImageBuffer, Rgba}; + + fn encode_png(image: image::RgbaImage) -> Vec { + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut cursor, ImageFormat::Png) + .expect("png should encode"); + cursor.into_inner() + } + + #[test] + fn slices_icon_spritesheet_by_connected_components_in_reading_order() { + let mut sheet: image::RgbaImage = + ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255])); + for y in 10..24 { + for x in 12..28 { + sheet.put_pixel(x, y, Rgba([240, 80, 80, 255])); + } + } + for y in 32..46 { + for x in 52..70 { + sheet.put_pixel(x, y, Rgba([80, 120, 240, 255])); + } + } + + let source = crate::DownloadedImage { + bytes: encode_png(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let icons = slice_generated_icon_spritesheet_by_connected_components( + &source, + &["返回按钮".to_string(), "设置按钮".to_string()], + ) + .expect("icons should slice"); + + assert_eq!(icons.len(), 2); + assert_eq!(icons[0].name, "返回按钮"); + assert_eq!(icons[1].name, "设置按钮"); + assert!(icons[0].width >= 16); + assert!(icons[0].height >= 14); + assert!(image::load_from_memory(icons[0].bytes.as_slice()).is_ok()); + } + + #[test] + fn rejects_when_connected_components_are_fewer_than_icon_names() { + let mut sheet: image::RgbaImage = + ImageBuffer::from_pixel(48, 48, Rgba([0, 255, 0, 255])); + for y in 12..24 { + for x in 12..24 { + sheet.put_pixel(x, y, Rgba([240, 80, 80, 255])); + } + } + let source = crate::DownloadedImage { + bytes: encode_png(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let error = slice_generated_icon_spritesheet_by_connected_components( + &source, + &["返回按钮".to_string(), "设置按钮".to_string()], + ) + .expect_err("missing component should fail"); + + assert!(error.to_string().contains("连通域数量不足")); + } +} + pub fn crop_generated_asset_sheet_view_edge_matte_with_options( image: image::DynamicImage, options: GeneratedAssetSheetAlphaOptions, diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index d341c6e3..267a0849 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -8,6 +8,7 @@ pub use vector_engine::{ VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, build_vector_engine_image_request_body, create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, - create_vector_engine_image_generation, download_remote_image, vector_engine_images_edit_url, - vector_engine_images_generation_url, + create_vector_engine_image_edit_with_references_and_model, + create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, + download_remote_image, vector_engine_images_edit_url, vector_engine_images_generation_url, }; diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index 70754cae..e65d08ee 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -13,8 +13,10 @@ use super::{ error::PlatformImageError, image_source::resolve_reference_images, request::{ - build_vector_engine_image_edit_request_log_params, build_vector_engine_image_request_body, - normalize_image_size, vector_engine_images_edit_url, vector_engine_images_generation_url, + build_vector_engine_image_edit_request_log_params, + build_vector_engine_image_request_body_with_model, normalize_image_size, + normalize_vector_engine_image_model, vector_engine_images_edit_url, + vector_engine_images_generation_url, }, response::handle_vector_engine_response, types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, @@ -31,12 +33,40 @@ pub async fn create_vector_engine_image_generation( reference_images: &[String], failure_context: &str, ) -> Result { + create_vector_engine_image_generation_with_model( + http_client, + settings, + GPT_IMAGE_2_MODEL, + prompt, + negative_prompt, + size, + candidate_count, + reference_images, + failure_context, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_vector_engine_image_generation_with_model( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + model: &str, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[String], + failure_context: &str, +) -> Result { + let model = normalize_vector_engine_image_model(model); if !reference_images.is_empty() { let resolved_references = resolve_reference_images(http_client, reference_images, failure_context).await?; - return create_vector_engine_image_edit_with_references( + return create_vector_engine_image_edit_with_references_and_model( http_client, settings, + model, prompt, negative_prompt, size, @@ -49,7 +79,8 @@ pub async fn create_vector_engine_image_generation( let request_url = vector_engine_images_generation_url(settings); let normalized_size = normalize_image_size(size); - let request_body = build_vector_engine_image_request_body( + let request_body = build_vector_engine_image_request_body_with_model( + model, prompt, negative_prompt, normalized_size.as_str(), @@ -125,6 +156,7 @@ pub async fn create_vector_engine_image_generation( provider = VECTOR_ENGINE_PROVIDER, endpoint = %request_url, status = response_status, + image_model = model, prompt_chars = prompt.chars().count(), size = %normalized_size, reference_image_count = reference_images.len(), @@ -181,6 +213,33 @@ pub async fn create_vector_engine_image_edit_with_references( reference_images: &[ReferenceImage], failure_context: &str, ) -> Result { + create_vector_engine_image_edit_with_references_and_model( + http_client, + settings, + GPT_IMAGE_2_MODEL, + prompt, + negative_prompt, + size, + candidate_count, + reference_images, + failure_context, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_vector_engine_image_edit_with_references_and_model( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + model: &str, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[ReferenceImage], + failure_context: &str, +) -> Result { + let model = normalize_vector_engine_image_model(model); if reference_images.is_empty() { return Err(PlatformImageError::InvalidRequest { provider: VECTOR_ENGINE_PROVIDER, @@ -191,6 +250,7 @@ pub async fn create_vector_engine_image_edit_with_references( let request_url = vector_engine_images_edit_url(settings); let normalized_size = normalize_image_size(size); let request_params = build_vector_engine_image_edit_request_log_params( + model, prompt, negative_prompt, normalized_size.as_str(), @@ -208,7 +268,7 @@ pub async fn create_vector_engine_image_edit_with_references( tracing::info!( provider = VECTOR_ENGINE_PROVIDER, endpoint = %request_url, - image_model = GPT_IMAGE_2_MODEL, + image_model = model, size = %normalized_size, candidate_count = candidate_count.clamp(1, 4), requested_candidate_count = candidate_count, @@ -230,6 +290,7 @@ pub async fn create_vector_engine_image_edit_with_references( match send_vector_engine_multipart_edit_request_with_curl( request_url.as_str(), settings.api_key.as_str(), + model, prompt, negative_prompt, normalized_size.as_str(), diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs index a5c6af67..308f27ce 100644 --- a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs +++ b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs @@ -8,7 +8,7 @@ use serde_json::Value; use super::{ audit::build_failure_audit, - constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER}, + constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, request::build_prompt_with_negative, types::ReferenceImage, @@ -115,6 +115,7 @@ pub(crate) async fn send_vector_engine_json_request_with_curl( pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( request_url: &str, api_key: &str, + model: &str, prompt: &str, negative_prompt: Option<&str>, normalized_size: &str, @@ -124,6 +125,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( ) -> Result { let request_url = request_url.to_string(); let api_key = api_key.to_string(); + let model = model.to_string(); let prompt = prompt.to_string(); let negative_prompt = negative_prompt.map(str::to_string); let normalized_size = normalized_size.to_string(); @@ -132,6 +134,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( send_multipart_edit_request_with_curl_blocking( request_url.as_str(), api_key.as_str(), + model.as_str(), prompt.as_str(), negative_prompt.as_deref(), normalized_size.as_str(), @@ -230,6 +233,7 @@ fn send_json_request_with_curl_blocking( fn send_multipart_edit_request_with_curl_blocking( request_url: &str, api_key: &str, + model: &str, prompt: &str, negative_prompt: Option<&str>, normalized_size: &str, @@ -239,7 +243,7 @@ fn send_multipart_edit_request_with_curl_blocking( ) -> Result { let mut form = Form::new(); form.part("model") - .contents(GPT_IMAGE_2_MODEL.as_bytes()) + .contents(model.as_bytes()) .add()?; form.part("prompt") .contents(build_prompt_with_negative(prompt, negative_prompt).as_bytes()) @@ -295,7 +299,7 @@ fn perform_curl_request(mut easy: Easy) -> Result Value { + build_vector_engine_image_request_body_with_model( + GPT_IMAGE_2_MODEL, + prompt, + negative_prompt, + size, + candidate_count, + _reference_images, + ) +} + +pub fn build_vector_engine_image_request_body_with_model( + model: &str, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + _reference_images: &[String], +) -> Value { + let model = normalize_vector_engine_image_model(model); let body = Map::from_iter([ ( "model".to_string(), - Value::String(GPT_IMAGE_2_MODEL.to_string()), + Value::String(model.to_string()), ), ( "prompt".to_string(), @@ -31,11 +50,20 @@ pub fn build_vector_engine_image_request_body( Value::Object(body) } +pub fn normalize_vector_engine_image_model(model: &str) -> &str { + match model.trim() { + "" => GPT_IMAGE_2_MODEL, + value => value, + } +} + pub fn normalize_image_size(size: &str) -> String { match size.trim() { "1024*1024" | "1024x1024" | "1:1" => "1024x1024", - "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152" - | "2k" => "1536x1024", + "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2k" => { + "1536x1024" + } + "1920*1080" | "1920x1080" | "2048*1152" | "2048x1152" | "2k-16:9" => "2048x1152", "1024*1536" | "1024x1536" | "9:16" => "1024x1536", value if !value.is_empty() => value, _ => "1024x1024", @@ -60,12 +88,14 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St } pub(crate) fn build_vector_engine_image_edit_request_log_params( + model: &str, prompt: &str, negative_prompt: Option<&str>, size: &str, candidate_count: u32, reference_images: &[ReferenceImage], ) -> Value { + let model = normalize_vector_engine_image_model(model); let prompt = prompt.trim(); let negative_prompt = negative_prompt .map(str::trim) @@ -91,7 +121,7 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params( .sum(); json!({ - "model": GPT_IMAGE_2_MODEL, + "model": model, "prompt": prompt, "negativePrompt": negative_prompt.unwrap_or_default(), "promptChars": prompt.chars().count(), @@ -125,6 +155,7 @@ mod tests { #[test] fn edit_request_log_params_include_reference_image_sizes_without_secrets_or_bytes() { let params = build_vector_engine_image_edit_request_log_params( + GPT_IMAGE_2_MODEL, " 拼图参考图重绘 ", Some(" 文字,水印 "), "1024x1024", diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index 8dadd9eb..208a3b9b 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -1,7 +1,8 @@ use platform_image::vector_engine::{ GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, build_vector_engine_image_request_body, - create_vector_engine_image_edit, create_vector_engine_image_generation, + build_vector_engine_image_request_body_with_model, create_vector_engine_image_edit, + create_vector_engine_image_generation, vector_engine_images_edit_url, vector_engine_images_generation_url, }; use std::{ @@ -43,6 +44,31 @@ fn vector_engine_module_exposes_provider_protocol_helpers() { ); } +#[test] +fn vector_engine_normalizes_2k_landscape_spec_size() { + let body = build_vector_engine_image_request_body("生成规范图", None, "2048x1152", 1, &[]); + + assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["size"], "2048x1152"); + assert_eq!(body["n"], 1); +} + +#[test] +fn vector_engine_request_body_can_use_nanobanana2_model() { + let body = build_vector_engine_image_request_body_with_model( + "gemini-3.1-flash-image-preview", + "生成图标 spritesheet", + None, + "512x512", + 1, + &[], + ); + + assert_eq!(body["model"], "gemini-3.1-flash-image-preview"); + assert_eq!(body["size"], "512x512"); + assert_eq!(body["n"], 1); +} + #[tokio::test] async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { let listener = TcpListener::bind("127.0.0.1:0") diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index e7c1e31b..b414eb1d 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -304,6 +304,48 @@ pub struct CharacterAnimationGenerateResponse { pub preview_video_path: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditorCharacterAnimationGenerateRequest { + pub source_layer_id: String, + pub source_image_src: String, + pub source_width: u32, + pub source_height: u32, + pub prompt_text: String, + pub resolution: String, + pub ratio: String, + pub frame_count: u32, + pub duration_seconds: u32, + pub price_mud_points: u32, + pub model: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditorCharacterAnimationFramePayload { + pub frame_index: u32, + pub image_src: String, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditorCharacterAnimationGenerateResponse { + pub ok: bool, + pub task_id: String, + pub model: String, + pub prompt: String, + pub preview_video_path: String, + pub frames: Vec, + pub frame_count: u32, + pub duration_seconds: u32, + pub frame_width: u32, + pub frame_height: u32, + pub fps: u32, + pub price_mud_points: u32, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationDraftPayload { @@ -815,6 +857,57 @@ mod tests { assert_eq!(payload["draftId"], json!("animation-import-1")); } + #[test] + fn editor_character_animation_request_uses_expected_camel_case_shape() { + let payload = serde_json::to_value(EditorCharacterAnimationGenerateRequest { + source_layer_id: "layer-1".to_string(), + source_image_src: "/generated-characters/hero/master.png".to_string(), + source_width: 768, + source_height: 1024, + prompt_text: "待机呼吸".to_string(), + resolution: "720p".to_string(), + ratio: "same".to_string(), + frame_count: 48, + duration_seconds: 6, + price_mud_points: 120, + model: "seedance2.0".to_string(), + }) + .expect("request should serialize"); + + assert_eq!(payload["sourceLayerId"], json!("layer-1")); + assert_eq!(payload["sourceImageSrc"], json!("/generated-characters/hero/master.png")); + assert_eq!(payload["priceMudPoints"], json!(120)); + assert_eq!(payload["model"], json!("seedance2.0")); + } + + #[test] + fn editor_character_animation_response_includes_frames_and_preview_video() { + let payload = serde_json::to_value(EditorCharacterAnimationGenerateResponse { + ok: true, + task_id: "task-1".to_string(), + model: "seedance2.0".to_string(), + prompt: "生成游戏角色动画".to_string(), + preview_video_path: "/generated-character-drafts/editor/layer/preview.mp4".to_string(), + frames: vec![EditorCharacterAnimationFramePayload { + frame_index: 1, + image_src: "/generated-animations/editor/layer/frame01.png".to_string(), + width: 768, + height: 1024, + }], + frame_count: 1, + duration_seconds: 4, + frame_width: 768, + frame_height: 1024, + fps: 8, + price_mud_points: 40, + }) + .expect("response should serialize"); + + assert_eq!(payload["previewVideoPath"], json!("/generated-character-drafts/editor/layer/preview.mp4")); + assert_eq!(payload["frames"][0]["imageSrc"], json!("/generated-animations/editor/layer/frame01.png")); + assert_eq!(payload["fps"], json!(8)); + } + #[test] fn character_workflow_cache_response_keeps_legacy_shape() { let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse { diff --git a/src/components/common/PlatformFloatingMenu.tsx b/src/components/common/PlatformFloatingMenu.tsx index b4a7f7c4..4cdcb247 100644 --- a/src/components/common/PlatformFloatingMenu.tsx +++ b/src/components/common/PlatformFloatingMenu.tsx @@ -1,10 +1,11 @@ -import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react'; type PlatformFloatingMenuProps = { children: ReactNode; className?: string; label?: string; placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'; + style?: CSSProperties; }; type PlatformFloatingMenuItemProps = Omit< @@ -24,6 +25,7 @@ export function PlatformFloatingMenu({ className, label, placement = 'top-end', + style, }: PlatformFloatingMenuProps) { return (
{children}
diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index f361a2e1..f604082b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1,15 +1,25 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import JSZip from 'jszip'; -import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; +import type { ContextType } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; +import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasEditorView } from './ImageCanvasEditorView'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); +const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); +const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorAssetMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); @@ -23,100 +33,79 @@ const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); -const openLoginModalMock = vi.hoisted(() => vi.fn()); -const authUiMockState = vi.hoisted(() => ({ - value: null as { - user: AuthUser | null; - canAccessProtectedData: boolean; - openLoginModal: typeof openLoginModalMock; - requireAuth: ReturnType; - openSettingsModal: ReturnType; - openAccountModal: ReturnType; - setCurrentUser: ReturnType; - logout: ReturnType; - musicVolume: number; - setMusicVolume: ReturnType; - platformTheme: 'light'; - setPlatformTheme: ReturnType; - isHydratingSettings: boolean; - isPersistingSettings: boolean; - settingsError: string | null; - } | null, -})); -vi.mock('../auth/AuthUiContext', () => ({ - useAuthUi: () => authUiMockState.value, -})); +type AuthValue = NonNullable>; -const imageEditorTestUser: AuthUser = { - id: 'user-editor-test', - publicUserCode: 'UEDITOR', - displayName: '画布测试用户', - avatarUrl: null, - phoneNumberMasked: '139****4806', - loginMethod: 'password', - bindingStatus: 'active', - wechatBound: false, -}; - -function createAuthUiMock(user: AuthUser | null = imageEditorTestUser) { +function createAuthValue(overrides: Partial = {}): AuthValue { return { - user, - canAccessProtectedData: Boolean(user), - openLoginModal: openLoginModalMock, - requireAuth: vi.fn((action: () => void) => { - if (user) { - action(); - return; - } - openLoginModalMock(action); - }), + user: null, + canAccessProtectedData: false, + openLoginModal: vi.fn(), + requireAuth: vi.fn((action: () => void) => action()), openSettingsModal: vi.fn(), openAccountModal: vi.fn(), setCurrentUser: vi.fn(), logout: vi.fn(), musicVolume: 0.5, setMusicVolume: vi.fn(), - platformTheme: 'light' as const, + platformTheme: 'light', setPlatformTheme: vi.fn(), isHydratingSettings: false, isPersistingSettings: false, settingsError: null, + ...overrides, }; } -const defaultProjectLayers = [ +const defaultEditorProjectResources = [ + { + resourceId: 'resource-puzzle', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + }, + { + resourceId: 'resource-big-fish', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/big-fish.webp', + width: 720, + height: 405, + sourceType: 'uploaded', + }, +]; + +const defaultEditorProjectLayers = [ { layerId: 'layer-puzzle', resourceId: 'resource-puzzle', title: '拼图素材', - src: '/creation-type-references/puzzle.webp', x: 470, y: 300, - width: 420, - height: 420, + width: 640, + height: 640, originalWidth: 640, originalHeight: 640, zIndex: 1, - sourceType: 'uploaded' as const, + sourceType: 'uploaded', }, { layerId: 'layer-big-fish', resourceId: 'resource-big-fish', title: '大鱼素材', - src: '/creation-type-references/big-fish.webp', x: 930, y: 360, - width: 420, - height: 236, + width: 720, + height: 405, originalWidth: 720, originalHeight: 405, zIndex: 2, - sourceType: 'uploaded' as const, + sourceType: 'uploaded', }, ]; -const defaultAccountAssets = [ +const defaultEditorAssetLibraryAssets = [ { assetId: 'asset-puzzle', folderId: 'project', @@ -124,7 +113,7 @@ const defaultAccountAssets = [ imageSrc: '/creation-type-references/puzzle.webp', width: 640, height: 640, - sourceType: 'uploaded' as const, + sourceType: 'uploaded', }, { assetId: 'asset-match3d', @@ -133,7 +122,7 @@ const defaultAccountAssets = [ imageSrc: '/creation-type-references/match3d.webp', width: 640, height: 640, - sourceType: 'uploaded' as const, + sourceType: 'uploaded', }, { assetId: 'asset-big-fish', @@ -142,7 +131,7 @@ const defaultAccountAssets = [ imageSrc: '/creation-type-references/big-fish.webp', width: 720, height: 405, - sourceType: 'uploaded' as const, + sourceType: 'uploaded', }, { assetId: 'asset-bark-battle', @@ -151,7 +140,7 @@ const defaultAccountAssets = [ imageSrc: '/creation-type-references/bark-battle.webp', width: 640, height: 900, - sourceType: 'uploaded' as const, + sourceType: 'uploaded', }, { assetId: 'asset-visual-novel', @@ -160,7 +149,7 @@ const defaultAccountAssets = [ imageSrc: '/creation-type-references/visual-novel.webp', width: 720, height: 405, - sourceType: 'uploaded' as const, + sourceType: 'uploaded', }, ]; @@ -176,6 +165,8 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { createEditorProjectResource: createEditorProjectResourceMock, deleteEditorAsset: deleteEditorAssetMock, deleteEditorAssetFolder: deleteEditorAssetFolderMock, + generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, + generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, loadEditorAssetLibrary: loadEditorAssetLibraryMock, loadEditorProject: loadEditorProjectMock, @@ -188,23 +179,30 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }); function dispatchPointerEvent( - target: Element | Window, + target: Element, type: string, - init: PointerEventInit & { pointerId: number }, + init: MouseEventInit & { pointerId: number }, ) { - const PointerEventConstructor = - typeof PointerEvent === 'undefined' ? MouseEvent : PointerEvent; - const event = new PointerEventConstructor(type, { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init, }); Object.defineProperty(event, 'pointerId', { value: init.pointerId }); - Object.defineProperty(event, 'pointerType', { value: 'mouse' }); - Object.defineProperty(event, 'isPrimary', { value: true }); fireEvent(target, event); } +function immediateAsync(value: T) { + return { + then(onFulfilled: (value: T) => unknown) { + onFulfilled(value); + return { + catch() {}, + }; + }, + }; +} + function createDataTransferStub() { const store = new Map(); return { @@ -224,10 +222,14 @@ function createDataTransferStub() { }; } -async function renderLoadedEditor() { - render(); - await screen.findByAltText('画布图片:拼图素材'); - await screen.findByRole('button', { name: '添加拼图素材' }); +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; } async function readZipText(zip: JSZip, path: string) { @@ -236,28 +238,20 @@ async function readZipText(zip: JSZip, path: string) { return file!.async('string'); } -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (error?: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - return { promise, resolve, reject }; -} - describe('ImageCanvasEditorView', () => { beforeEach(() => { - authUiMockState.value = createAuthUiMock(); - loadOrCreateRecentEditorProjectMock.mockResolvedValue({ + loadOrCreateRecentEditorProjectMock.mockImplementation(() => + immediateAsync({ projectId: 'editor-project-default', title: '默认项目', viewport: { x: 0, y: 0, scale: 1 }, - layers: defaultProjectLayers, - resources: [], + layers: defaultEditorProjectLayers, + resources: defaultEditorProjectResources, updatedAt: '2026-06-12T00:00:00.000Z', - }); - loadEditorAssetLibraryMock.mockResolvedValue({ + }), + ); + loadEditorAssetLibraryMock.mockImplementation(() => + immediateAsync({ folders: [ { folderId: 'project', @@ -267,8 +261,9 @@ describe('ImageCanvasEditorView', () => { systemDefault: true, }, ], - assets: defaultAccountAssets, - }); + assets: defaultEditorAssetLibraryAssets, + }), + ); createEditorAssetMock.mockImplementation(async (input) => ({ assetId: `persisted-${input.label}`, folderId: input.folderId, @@ -278,14 +273,6 @@ describe('ImageCanvasEditorView', () => { height: input.height, sourceType: input.sourceType, })); - renameEditorProjectMock.mockImplementation(async (projectId, title) => ({ - projectId, - title, - viewport: { x: 0, y: 0, scale: 1 }, - layers: defaultProjectLayers, - resources: [], - updatedAt: '2026-06-12T00:00:00.000Z', - })); createEditorAssetFolderMock.mockResolvedValue({ folderId: 'folder-role-persisted', label: '角色上传', @@ -301,6 +288,14 @@ describe('ImageCanvasEditorView', () => { height: 640, sourceType: 'uploaded', })); + renameEditorProjectMock.mockImplementation(async (projectId, title) => ({ + projectId, + title, + viewport: { x: 0, y: 0, scale: 1 }, + layers: defaultEditorProjectLayers, + resources: defaultEditorProjectResources, + updatedAt: '2026-06-12T00:00:00.000Z', + })); updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({ folderId, label: input.label ?? '角色上传', @@ -320,14 +315,16 @@ describe('ImageCanvasEditorView', () => { assets: [], }); deleteEditorAssetMock.mockResolvedValue({}); - createEditorProjectResourceMock.mockImplementation(async (projectId, input) => ({ - resourceId: `resource-${projectId}-${input.width}`, - projectId, - imageSrc: input.imageSrc, - width: input.width, - height: input.height, - sourceType: input.sourceType, - })); + createEditorProjectResourceMock.mockImplementation( + async (projectId, input) => ({ + resourceId: `resource-${projectId}-${input.width}`, + projectId, + imageSrc: input.imageSrc, + width: input.width, + height: input.height, + sourceType: input.sourceType, + }), + ); saveEditorProjectLayoutMock.mockResolvedValue({}); }); @@ -335,6 +332,8 @@ describe('ImageCanvasEditorView', () => { vi.useRealTimers(); vi.restoreAllMocks(); generateEditorImageMock.mockReset(); + generateEditorIconSpritesheetMock.mockReset(); + generateEditorCharacterAnimationMock.mockReset(); editEditorImageMock.mockReset(); createEditorAssetMock.mockReset(); createEditorProjectResourceMock.mockReset(); @@ -348,7 +347,6 @@ describe('ImageCanvasEditorView', () => { loadOrCreateRecentEditorProjectMock.mockReset(); renameEditorProjectMock.mockReset(); saveEditorProjectLayoutMock.mockReset(); - openLoginModalMock.mockReset(); window.history.replaceState(null, '', '/editor/canvas'); }); @@ -370,55 +368,52 @@ describe('ImageCanvasEditorView', () => { render(); await waitFor(() => { - expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-query'); + expect(loadEditorProjectMock).toHaveBeenCalledWith( + 'editor-project-query', + ); }); expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled(); }); - it('opens the login modal when direct project loading is unauthorized', async () => { - authUiMockState.value = createAuthUiMock(null); - loadEditorProjectMock.mockRejectedValueOnce( + it('shows the loaded project title and a topbar entry back to projects', async () => { + render(); + + expect(await screen.findByRole('heading', { name: '默认项目' })).toBeTruthy(); + const projectLink = screen.getByRole('link', { name: '返回项目页面' }); + + expect(projectLink.getAttribute('href')).toBe('/project'); + expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull(); + }); + + it('opens login modal when the asset library is unauthorized', async () => { + const openLoginModal = vi.fn(); + loadEditorAssetLibraryMock.mockRejectedValueOnce( new ApiClientError({ message: '未授权访问', status: 401, code: 'UNAUTHORIZED', }), ); - window.history.replaceState( - null, - '', - '/editor/canvas?projectid=editor-project-private', + + render( + + + , ); - render(); - await waitFor(() => { - expect(openLoginModalMock).toHaveBeenCalledWith(expect.any(Function)); + expect(openLoginModal).toHaveBeenCalledTimes(1); }); - expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull(); - }); - - it('offers a topbar entry back to the project page', async () => { - await renderLoadedEditor(); - - const projectLink = screen.getByRole('link', { name: '返回项目页面' }); - - expect(projectLink.getAttribute('href')).toBe('/project'); - }); - - it('uses the loaded project name as the canvas title', async () => { - await renderLoadedEditor(); - - expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy(); - expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull(); }); it('renames the current project from the canvas topbar', async () => { - await renderLoadedEditor(); + render(); + await screen.findByRole('heading', { name: '默认项目' }); fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' })); - const titleInput = screen.getByLabelText('项目名称'); - fireEvent.change(titleInput, { target: { value: '新画布项目' } }); + fireEvent.change(screen.getByLabelText('项目名称'), { + target: { value: '新画布项目' }, + }); fireEvent.click(screen.getByRole('button', { name: '保存项目名称' })); await waitFor(() => { @@ -428,23 +423,41 @@ describe('ImageCanvasEditorView', () => { ); }); expect(await screen.findByRole('heading', { name: '新画布项目' })).toBeTruthy(); - expect(screen.queryByLabelText('项目名称')).toBeNull(); }); - it('cancels project rename editing with Escape', async () => { - await renderLoadedEditor(); + it('does not inject built-in mock assets when the persisted library is empty', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-empty', + title: '空画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [], + }); - fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' })); - const titleInput = screen.getByLabelText('项目名称'); - fireEvent.change(titleInput, { target: { value: '不会保存' } }); - fireEvent.keyDown(titleInput, { key: 'Escape' }); + render(); - expect(renameEditorProjectMock).not.toHaveBeenCalled(); - expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy(); - expect(screen.queryByLabelText('项目名称')).toBeNull(); + expect( + await screen.findByRole('region', { name: '项目素材' }), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull(); + expect(screen.queryByRole('button', { name: '添加大鱼素材' })).toBeNull(); + expect(screen.queryByAltText(/画布图片:拼图素材/u)).toBeNull(); }); - it('exports valid canvas assets as a zip from the topbar', async () => { + it('exports valid canvas assets as a zip from the topbar with metadata', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-export', title: '导出项目', @@ -560,17 +573,23 @@ describe('ImageCanvasEditorView', () => { return 'blob:editor-export'; }); URL.revokeObjectURL = vi.fn(); - HTMLAnchorElement.prototype.click = vi.fn(function click(this: HTMLAnchorElement) { + HTMLAnchorElement.prototype.click = vi.fn(function click( + this: HTMLAnchorElement, + ) { downloadName = this.download; }); try { render(); + await screen.findByRole('heading', { name: '导出项目' }); await waitFor(() => { expect( - (screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement) - .disabled, + ( + screen.getByRole('button', { + name: '下载画布素材', + }) as HTMLButtonElement + ).disabled, ).toBe(false); }); fireEvent.click(screen.getByRole('button', { name: '下载画布素材' })); @@ -615,8 +634,8 @@ describe('ImageCanvasEditorView', () => { it('disables the canvas asset export entry when there are no valid layers', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-empty', - title: '空项目', + projectId: 'editor-project-empty-export', + title: '空导出项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], @@ -636,23 +655,18 @@ describe('ImageCanvasEditorView', () => { }); render(); - await screen.findByRole('heading', { name: '空项目' }); + await screen.findByRole('heading', { name: '空导出项目' }); expect( - (screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement) - .disabled, + ( + screen.getByRole('button', { + name: '下载画布素材', + }) as HTMLButtonElement + ).disabled, ).toBe(true); }); - it('does not inject built-in mock assets or layers when persistence returns empty data', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-empty', - title: '空项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], - updatedAt: '2026-06-12T00:00:00.000Z', - }); + it('keeps only one default asset folder when the persisted library returns duplicated defaults', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ { @@ -662,160 +676,42 @@ describe('ImageCanvasEditorView', () => { collapsed: false, systemDefault: true, }, + { + folderId: 'legacy-project', + label: '旧项目素材', + sortOrder: 1, + collapsed: false, + systemDefault: true, + }, ], assets: [], }); render(); - await waitFor(() => { - expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); - }); - expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull(); - expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull(); - expect(screen.getByRole('region', { name: '项目素材' })).toBeTruthy(); + expect( + await screen.findByRole('region', { name: '项目素材' }), + ).toBeTruthy(); + expect(screen.queryByRole('region', { name: '旧项目素材' })).toBeNull(); + expect(screen.getAllByRole('button', { name: /上传到/u })).toHaveLength(1); }); - it('keeps canvas layers when their account asset has been removed from the library', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-invalid-layer', - title: '包含失效素材的项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-valid-asset', - resourceId: 'resource-valid-asset', - title: '账号素材A', - src: 'data:image/png;base64,YQ==', - x: 120, - y: 120, - width: 320, - height: 240, - originalWidth: 320, - originalHeight: 240, - zIndex: 1, - sourceType: 'uploaded', - sourceAssetId: 'asset-a', - }, - { - layerId: 'layer-missing-asset', - resourceId: 'resource-missing-asset', - title: '已删除素材', - src: 'data:image/png;base64,bWlzc2luZw==', - x: 480, - y: 120, - width: 320, - height: 240, - originalWidth: 320, - originalHeight: 240, - zIndex: 2, - sourceType: 'uploaded', - sourceAssetId: 'asset-missing', - }, - ], - resources: [], - updatedAt: '2026-06-12T00:00:00.000Z', - }); - loadEditorAssetLibraryMock.mockResolvedValueOnce({ - folders: [ - { - folderId: 'project', - label: '项目素材', - sortOrder: 0, - collapsed: false, - systemDefault: true, - }, - ], - assets: [ - { - assetId: 'asset-a', - folderId: 'project', - label: '账号素材A', - imageSrc: 'data:image/png;base64,YQ==', - width: 320, - height: 240, - sourceType: 'uploaded', - }, - ], - }); - + it('toggles the shared sidebar from canvas panel buttons', () => { render(); - await waitFor(() => { - expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy(); - expect(screen.getByAltText('画布图片:已删除素材')).toBeTruthy(); - }); - expect(saveEditorProjectLayoutMock).not.toHaveBeenCalledWith( - 'editor-project-invalid-layer', - expect.objectContaining({ - layers: [ - expect.objectContaining({ - layerId: 'layer-valid-asset', - sourceAssetId: 'asset-a', - }), - ], - }), - ); - }); - - it('keeps resource-backed canvas layers when their account asset is not loaded', async () => { - loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ - projectId: 'editor-project-resource-layer', - title: '历史工程资源项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [ - { - layerId: 'layer-resource-backed', - resourceId: 'resource-backed', - title: '历史画布图片', - src: 'data:image/png;base64,aGlzdG9yeQ==', - x: 120, - y: 120, - width: 320, - height: 240, - originalWidth: 320, - originalHeight: 240, - zIndex: 1, - sourceType: 'uploaded', - }, - ], - resources: [], - updatedAt: '2026-06-12T00:00:00.000Z', - }); - loadEditorAssetLibraryMock.mockResolvedValueOnce({ - folders: [ - { - folderId: 'project', - label: '项目素材', - sortOrder: 0, - collapsed: false, - systemDefault: true, - }, - ], - assets: [], - }); - - render(); - - await waitFor(() => { - expect(screen.getByAltText('画布图片:历史画布图片')).toBeTruthy(); - }); - expect(saveEditorProjectLayoutMock).not.toHaveBeenCalledWith( - 'editor-project-resource-layer', - expect.objectContaining({ layers: [] }), - ); - }); - - it('toggles the shared sidebar from canvas panel buttons', async () => { - await renderLoadedEditor(); - const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); - const assetsButton = within(panelToolbar).getByRole('button', { name: '打开素材' }); - const layersButton = within(panelToolbar).getByRole('button', { name: '打开图层' }); + const assetsButton = within(panelToolbar).getByRole('button', { + name: '打开素材', + }); + const layersButton = within(panelToolbar).getByRole('button', { + name: '打开图层', + }); expect(within(sidebar).getByText('素材')).toBeTruthy(); - expect(within(sidebar).getByRole('button', { name: '添加拼图素材' })).toBeTruthy(); + expect( + within(sidebar).getByRole('button', { name: '添加拼图素材' }), + ).toBeTruthy(); expect(assetsButton.getAttribute('aria-pressed')).toBe('true'); expect(screen.queryByRole('button', { name: '打开已生成文件' })).toBeNull(); expect(screen.queryByRole('button', { name: '收起素材栏' })).toBeNull(); @@ -823,7 +719,9 @@ describe('ImageCanvasEditorView', () => { fireEvent.click(layersButton); - const layerSidebar = screen.getByRole('complementary', { name: '图片资源栏' }); + const layerSidebar = screen.getByRole('complementary', { + name: '图片资源栏', + }); expect(within(layerSidebar).getByText('图层')).toBeTruthy(); expect( within(layerSidebar).getByRole('button', { name: '选择图层拼图素材' }), @@ -833,19 +731,27 @@ describe('ImageCanvasEditorView', () => { fireEvent.click(layersButton); - expect(screen.queryByRole('complementary', { name: '图片资源栏' })).toBeNull(); + expect( + screen.queryByRole('complementary', { name: '图片资源栏' }), + ).toBeNull(); expect(layersButton.getAttribute('aria-pressed')).toBe('false'); }); it('groups assets by folder and renames sidebar materials', async () => { const user = userEvent.setup(); - await renderLoadedEditor(); + render(); const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); - expect(within(sidebar).getByRole('region', { name: '项目素材' })).toBeTruthy(); - expect(within(sidebar).queryByRole('region', { name: '参考素材' })).toBeNull(); + expect( + within(sidebar).getByRole('region', { name: '项目素材' }), + ).toBeTruthy(); + expect( + within(sidebar).queryByRole('region', { name: '参考素材' }), + ).toBeNull(); - await user.click(screen.getByRole('button', { name: '重命名素材拼图素材' })); + await user.click( + screen.getByRole('button', { name: '重命名素材拼图素材' }), + ); const renameInput = screen.getByLabelText('重命名素材拼图素材'); expect(renameInput.className).toContain('platform-text-field'); expect(renameInput.className).toContain( @@ -853,7 +759,9 @@ describe('ImageCanvasEditorView', () => { ); await user.clear(renameInput); await user.type(renameInput, '主视觉素材'); - await user.click(screen.getByRole('button', { name: '保存素材拼图素材名称' })); + await user.click( + screen.getByRole('button', { name: '保存素材拼图素材名称' }), + ); expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull(); await user.click(screen.getByRole('button', { name: '添加主视觉素材' })); @@ -892,41 +800,27 @@ describe('ImageCanvasEditorView', () => { ); const customFolder = screen.getByRole('region', { name: '角色上传' }); - expect(within(customFolder).getByRole('button', { name: '添加角色草图.png' })).toBeTruthy(); - expect(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' })).toBeTruthy(); - - await user.click(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' })); - - expect(screen.queryByRole('button', { name: '添加角色草图.png' })).toBeNull(); - expect(screen.queryByAltText('画布图片:角色草图.png')).toBeNull(); - }); - - it('keeps only one non-deletable default asset folder', async () => { - loadEditorAssetLibraryMock.mockResolvedValueOnce({ - folders: [ - { - folderId: 'project', - label: '项目素材', - sortOrder: 0, - collapsed: false, - systemDefault: true, - }, - { - folderId: 'legacy-default', - label: '默认素材', - sortOrder: 1, - collapsed: false, - systemDefault: true, - }, - ], - assets: [], + await waitFor(() => { + expect( + within(customFolder).getByRole('button', { name: '添加角色草图.png' }), + ).toBeTruthy(); + expect( + within(customFolder).getByRole('button', { + name: '删除素材角色草图.png', + }), + ).toBeTruthy(); }); - render(); - await screen.findByRole('region', { name: '项目素材' }); + await user.click( + within(customFolder).getByRole('button', { + name: '删除素材角色草图.png', + }), + ); - expect(screen.queryByRole('button', { name: '删除文件夹项目素材' })).toBeNull(); - expect(screen.getByRole('button', { name: '删除文件夹默认素材' })).toBeTruthy(); + expect( + screen.queryByRole('button', { name: '添加角色草图.png' }), + ).toBeNull(); + expect(screen.queryByAltText('画布图片:角色草图.png')).toBeNull(); }); it('renames and deletes asset folders through the persisted asset library API', async () => { @@ -961,13 +855,17 @@ describe('ImageCanvasEditorView', () => { ); await user.clear(folderRenameInput); await user.type(folderRenameInput, '角色参考'); - await user.click(screen.getByRole('button', { name: '保存文件夹角色名称' })); + await user.click( + screen.getByRole('button', { name: '保存文件夹角色名称' }), + ); expect(updateEditorAssetFolderMock).toHaveBeenCalledWith('folder-role', { label: '角色参考', }); - await user.click(screen.getByRole('button', { name: '删除文件夹角色参考' })); + await user.click( + screen.getByRole('button', { name: '删除文件夹角色参考' }), + ); expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role'); }); @@ -1004,8 +902,12 @@ describe('ImageCanvasEditorView', () => { }); render(); - const sourceAsset = await screen.findByRole('button', { name: '添加拼图素材' }); - const sourceAssetRow = sourceAsset.closest('.image-canvas-editor__asset-row'); + const sourceAsset = await screen.findByRole('button', { + name: '添加拼图素材', + }); + const sourceAssetRow = sourceAsset.closest( + '.image-canvas-editor__asset-row', + ); const projectFolder = screen.getByRole('region', { name: '项目素材' }); const roleFolder = screen.getByRole('region', { name: '角色' }); const dataTransfer = createDataTransferStub(); @@ -1013,133 +915,28 @@ describe('ImageCanvasEditorView', () => { if (!sourceAssetRow) { throw new Error('asset row should exist'); } - fireEvent.dragStart(sourceAssetRow, { - dataTransfer, - }); - fireEvent.dragOver(roleFolder, { - dataTransfer, - }); + fireEvent.dragStart(sourceAssetRow, { dataTransfer }); + fireEvent.dragOver(roleFolder, { dataTransfer }); await waitFor(() => { expect(screen.queryByText('添加到素材')).toBeNull(); expect(roleFolder.className).toContain( 'image-canvas-editor__asset-folder--move-target', ); }); - fireEvent.drop(roleFolder, { - dataTransfer, - }); + fireEvent.drop(roleFolder, { dataTransfer }); expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-puzzle', { folderId: 'folder-role', }); - expect(within(projectFolder).queryByRole('button', { name: '添加拼图素材' })).toBeNull(); + expect( + within(projectFolder).queryByRole('button', { name: '添加拼图素材' }), + ).toBeNull(); expect( within(roleFolder).getByRole('button', { name: '添加拼图素材' }), ).toBeTruthy(); expect(createEditorAssetMock).not.toHaveBeenCalled(); }); - it('pins the asset move target when the target folder name is outside the asset panel viewport', async () => { - loadEditorAssetLibraryMock.mockResolvedValueOnce({ - folders: [ - { - folderId: 'project', - label: '项目素材', - sortOrder: 0, - collapsed: false, - systemDefault: true, - }, - { - folderId: 'folder-role', - label: '角色', - sortOrder: 100, - collapsed: false, - systemDefault: false, - }, - ], - assets: [ - { - assetId: 'asset-puzzle', - folderId: 'project', - label: '拼图素材', - imageSrc: '/creation-type-references/puzzle.webp', - width: 640, - height: 640, - sourceType: 'uploaded', - }, - ], - }); - render(); - - const sourceAsset = await screen.findByRole('button', { name: '添加拼图素材' }); - const sourceAssetRow = sourceAsset.closest('.image-canvas-editor__asset-row'); - const roleFolder = screen.getByRole('region', { name: '角色' }); - const assetList = document.querySelector('.image-canvas-editor__asset-list'); - const roleHeader = roleFolder.querySelector( - '[data-asset-folder-header-id="folder-role"]', - ); - const dataTransfer = createDataTransferStub(); - - if (!sourceAssetRow || !assetList || !roleHeader) { - throw new Error('asset drag elements should exist'); - } - - vi.spyOn(assetList, 'getBoundingClientRect').mockReturnValue({ - x: 0, - y: 0, - top: 0, - left: 0, - right: 260, - bottom: 200, - width: 260, - height: 200, - toJSON: () => ({}), - } as DOMRect); - const roleHeaderRect = vi - .spyOn(roleHeader, 'getBoundingClientRect') - .mockReturnValue({ - x: 0, - y: 260, - top: 260, - left: 0, - right: 260, - bottom: 288, - width: 260, - height: 28, - toJSON: () => ({}), - } as DOMRect); - - fireEvent.dragStart(sourceAssetRow, { dataTransfer }); - fireEvent.dragOver(roleFolder, { dataTransfer }); - - await waitFor(() => { - expect( - document.querySelector( - '.image-canvas-editor__asset-folder-sticky-target', - )?.textContent, - ).toContain('角色'); - }); - - roleHeaderRect.mockReturnValue({ - x: 0, - y: 40, - top: 40, - left: 0, - right: 260, - bottom: 68, - width: 260, - height: 28, - toJSON: () => ({}), - } as DOMRect); - fireEvent.dragOver(roleFolder, { dataTransfer }); - - await waitFor(() => { - expect( - document.querySelector('.image-canvas-editor__asset-folder-sticky-target'), - ).toBeNull(); - }); - }); - it('uploads multiple files as account-level assets without adding canvas layers', async () => { render(); @@ -1149,14 +946,67 @@ describe('ImageCanvasEditorView', () => { ]); await waitFor(() => { - expect(screen.getByRole('button', { name: '添加第一张.png' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '添加第二张.png' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '添加第一张.png' }), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '添加第二张.png' }), + ).toBeTruthy(); }); expect(createEditorAssetMock).toHaveBeenCalledTimes(2); expect(screen.queryByAltText('画布图片:第一张.png')).toBeNull(); expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull(); }); + it('opens login before uploading assets while logged out and resumes after login', async () => { + const openLoginModal = vi.fn(); + const authValue = createAuthValue({ openLoginModal }); + + const { rerender } = render( + + + , + ); + + await userEvent.upload(screen.getByLabelText('上传图片文件'), [ + new File(['image'], '登录后上传.png', { type: 'image/png' }), + ]); + + expect(openLoginModal).toHaveBeenCalledTimes(1); + expect(createEditorAssetMock).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: '上传失败登录后上传.png' })).toBeNull(); + + const resumeUpload = openLoginModal.mock.calls[0]?.[0]; + expect(typeof resumeUpload).toBe('function'); + rerender( + + + , + ); + act(() => { + (resumeUpload as () => void)(); + }); + + await waitFor(() => { + expect(createEditorAssetMock).toHaveBeenCalledTimes(1); + }); + }); + it('shows an uploading placeholder card before restoring the normal asset card', async () => { const deferredAsset = createDeferred<{ assetId: string; @@ -1167,43 +1017,77 @@ describe('ImageCanvasEditorView', () => { height: number; sourceType: 'uploaded'; }>(); - createEditorAssetMock.mockImplementationOnce(async (input) => { - await deferredAsset.promise; - return { - assetId: 'asset-uploading-finished', - folderId: input.folderId, - label: input.label, - imageSrc: input.imageSrc, - width: input.width, - height: input.height, - sourceType: 'uploaded', - }; - }); + createEditorAssetMock.mockReturnValueOnce(deferredAsset.promise); render(); - await userEvent.upload( - screen.getByLabelText('上传图片文件'), - new File(['image'], '上传进度.png', { type: 'image/png' }), - ); + await userEvent.upload(screen.getByLabelText('上传图片文件'), [ + new File(['image'], '素材上传进度.png', { type: 'image/png' }), + ]); - expect(await screen.findByRole('button', { name: '上传中上传进度.png' })).toBeTruthy(); - expect(screen.getByLabelText('素材上传进度.png上传进度')).toBeTruthy(); - expect(screen.queryByRole('button', { name: '添加上传进度.png' })).toBeNull(); + expect( + await screen.findByLabelText('素材素材上传进度.png上传进度'), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: '上传中素材上传进度.png' })).toBeTruthy(); deferredAsset.resolve({ - assetId: 'asset-uploading-finished', + assetId: 'asset-upload-progress', folderId: 'project', - label: '上传进度.png', - imageSrc: 'data:image/png;base64,aW1hZ2U=', + label: '素材上传进度.png', + imageSrc: 'data:image/png;base64,cHJvZ3Jlc3M=', width: 420, height: 315, sourceType: 'uploaded', }); await waitFor(() => { - expect(screen.getByRole('button', { name: '添加上传进度.png' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '添加素材上传进度.png' }), + ).toBeTruthy(); }); - expect(screen.queryByRole('button', { name: '上传中上传进度.png' })).toBeNull(); + expect( + screen.queryByLabelText('素材素材上传进度.png上传进度'), + ).toBeNull(); + }); + + it('opens login when asset creation returns unauthorized during upload', async () => { + const openLoginModal = vi.fn(); + createEditorAssetMock.mockRejectedValueOnce( + new ApiClientError({ + message: '未授权访问', + status: 401, + code: 'UNAUTHORIZED', + }), + ); + + render( + + + , + ); + + await userEvent.upload(screen.getByLabelText('上传图片文件'), [ + new File(['image'], '过期登录.png', { type: 'image/png' }), + ]); + + await waitFor(() => { + expect(openLoginModal).toHaveBeenCalledTimes(1); + }); + expect(screen.getByText('请先登录')).toBeTruthy(); }); it('supports asset selection mode and batch delete with shared toolbar', async () => { @@ -1247,10 +1131,14 @@ describe('ImageCanvasEditorView', () => { const batchToolbar = screen.getByRole('toolbar', { name: '素材批量操作' }); expect(within(batchToolbar).getByText(/已选 1/u)).toBeTruthy(); - await user.click(within(batchToolbar).getByRole('button', { name: '删除' })); + await user.click( + within(batchToolbar).getByRole('button', { name: '删除' }), + ); expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); - expect(screen.queryByRole('button', { name: '选择素材账号素材A' })).toBeNull(); + expect( + screen.queryByRole('button', { name: '选择素材账号素材A' }), + ).toBeNull(); }); it('removes canvas layers linked to deleted assets', async () => { @@ -1288,7 +1176,9 @@ describe('ImageCanvasEditorView', () => { }); render(); - await user.click(await screen.findByRole('button', { name: '添加账号素材A' })); + await user.click( + await screen.findByRole('button', { name: '添加账号素材A' }), + ); await user.click(screen.getByRole('button', { name: '添加账号素材B' })); expect(screen.getByAltText('画布图片:账号素材A')).toBeTruthy(); expect(screen.getByAltText('画布图片:账号素材B')).toBeTruthy(); @@ -1360,7 +1250,9 @@ describe('ImageCanvasEditorView', () => { const firstAssetButton = await screen.findByRole('button', { name: '添加账号素材A', }); - const secondAssetButton = screen.getByRole('button', { name: '添加账号素材B' }); + const secondAssetButton = screen.getByRole('button', { + name: '添加账号素材B', + }); const assetList = firstAssetButton.closest( '.image-canvas-editor__asset-list', ) as HTMLElement; @@ -1405,11 +1297,11 @@ describe('ImageCanvasEditorView', () => { }); await user.click(screen.getByRole('button', { name: '素材选择模式' })); - dispatchPointerEvent(firstAssetButton.closest('[data-asset-id]') as HTMLElement, 'pointerdown', { + dispatchPointerEvent(assetList, 'pointerdown', { button: 0, pointerId: 88, - clientX: 32, - clientY: 140, + clientX: 8, + clientY: 100, }); dispatchPointerEvent(assetList, 'pointermove', { button: 0, @@ -1428,14 +1320,14 @@ describe('ImageCanvasEditorView', () => { expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy(); }); - it('shows image size on hover and placeholder toolbar after selecting a layer', async () => { + it('shows image resolution on hover and placeholder toolbar after selecting a layer', () => { const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - await renderLoadedEditor(); + render(); const canvasImage = screen.getByAltText('画布图片:拼图素材'); fireEvent.mouseEnter(canvasImage.closest('button')!); - const sizeBadge = screen.getByText('420 x 420 px'); + const sizeBadge = screen.getByText('640 x 640 px'); expect(sizeBadge.className).toContain('rounded-full'); expect(sizeBadge.className).toContain('image-canvas-editor__size-badge'); @@ -1459,86 +1351,137 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy(); }); - it('treats puzzle material as a normal asset without generated metadata tools', async () => { - await renderLoadedEditor(); + it('opens image info for uploaded canvas images without generated edit tools', () => { + render(); - fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { - button: 0, - pointerId: 61, - clientX: 120, - clientY: 120, + fireEvent.pointerDown( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + button: 0, + pointerId: 61, + clientX: 120, + clientY: 120, + }, + ); + + const infoButton = screen.getByRole('button', { + name: '查看拼图素材图片信息', }); + expect(infoButton.className).toContain( + 'image-canvas-editor__metadata-corner', + ); + fireEvent.click(infoButton); - expect(screen.queryByRole('button', { name: '查看拼图素材元数据' })).toBeNull(); + const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' }); + expect(within(infoPanel).getByText('图片类型')).toBeTruthy(); + expect(within(infoPanel).getByText('上传图片')).toBeTruthy(); + expect(within(infoPanel).getByText('生成输入')).toBeTruthy(); + expect( + infoPanel.querySelector('.image-canvas-editor__metadata-inputs') + ?.textContent, + ).toBe('-'); + expect(within(infoPanel).queryByText('Prompt')).toBeNull(); + expect(within(infoPanel).getByText('Model')).toBeTruthy(); + expect(within(infoPanel).queryByText('Size')).toBeNull(); + expect(within(infoPanel).getByText('Resolution')).toBeTruthy(); + expect(within(infoPanel).getByText('640 x 640 px')).toBeTruthy(); + expect( + within(infoPanel).queryByRole('button', { name: '复制Prompt' }), + ).toBeNull(); expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull(); }); - it('deletes the selected layer from the floating toolbar', async () => { - await renderLoadedEditor(); + it('hydrates canvas images from Resolution instead of saved Size', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-resolution', + title: '原分辨率画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-resolution', + resourceId: 'resource-resolution', + title: '旧布局图片', + src: 'data:image/png;base64,cmVzb2x1dGlvbg==', + x: 120, + y: 140, + width: 320, + height: 240, + originalWidth: 1536, + originalHeight: 1024, + zIndex: 2, + sourceType: 'generated', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'resolution-task-1', + }, + ], + resources: [], + updatedAt: '2026-06-16T00:00:00.000Z', + }); + render(); + + const canvasImage = await screen.findByAltText('画布图片:旧布局图片'); + const canvasLayer = canvasImage.closest('button') as HTMLElement; + expect(Number.parseFloat(canvasLayer.style.width)).toBe(1536); + expect(Number.parseFloat(canvasLayer.style.height)).toBe(1024); + + fireEvent.mouseEnter(canvasLayer); + expect(screen.getByText('1536 x 1024 px')).toBeTruthy(); + fireEvent.click( + screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!, + ); + const infoPanel = screen.getByRole('dialog', { + name: '旧布局图片图片信息', + }); + expect(within(infoPanel).queryByText('Size')).toBeNull(); + expect(within(infoPanel).getByText('Resolution')).toBeTruthy(); + expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy(); + }); + + it('deletes the selected layer from the floating toolbar', () => { + render(); expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); - fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { - button: 0, - pointerId: 51, - clientX: 120, - clientY: 120, - }); + fireEvent.pointerDown( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + button: 0, + pointerId: 51, + clientX: 120, + clientY: 120, + }, + ); fireEvent.click(screen.getByRole('button', { name: '删除图片' })); expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull(); expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); }); - it('applies floating toolbar delete to every selected canvas layer', async () => { - await renderLoadedEditor(); + it('deletes the selected layer with Backspace when focus is outside text inputs', async () => { + render(); - fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { - button: 0, - pointerId: 151, - clientX: 120, - clientY: 120, - }); - fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { - pointerId: 151, - clientX: 120, - clientY: 120, - }); - fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); - fireEvent.pointerDown(screen.getByAltText('画布图片:大鱼素材').closest('button')!, { - button: 0, - pointerId: 152, - clientX: 520, - clientY: 180, - shiftKey: true, - }); - fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { - pointerId: 152, - clientX: 520, - clientY: 180, - }); - fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); + expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); - await waitFor(() => { - expect( - screen.getByAltText('画布图片:拼图素材').closest('button')?.className, - ).toContain('image-canvas-editor__layer--selected'); - expect( - screen.getByAltText('画布图片:大鱼素材').closest('button')?.className, - ).toContain('image-canvas-editor__layer--selected'); + fireEvent.pointerDown( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + button: 0, + pointerId: 52, + clientX: 120, + clientY: 120, + }, + ); + await act(async () => { + fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); - fireEvent.click(screen.getByRole('button', { name: '删除图片' })); expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull(); - expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull(); - - fireEvent.click(screen.getByRole('button', { name: '撤销' })); - expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); }); - it('drops multiple image files on the canvas as new canvas layers', async () => { - await renderLoadedEditor(); + it('drops an image file on the canvas as a new canvas layer', async () => { + render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); @@ -1548,43 +1491,53 @@ describe('ImageCanvasEditorView', () => { clientX: 430, clientY: 260, dataTransfer: { - files: [ - new File(['image-a'], '测试上传A.png', { type: 'image/png' }), - new File(['image-b'], '测试上传B.png', { type: 'image/png' }), - ], + files: [new File(['image'], '测试上传.png', { type: 'image/png' })], types: ['Files'], }, }); await waitFor(() => { - expect(screen.getByAltText('画布图片:测试上传A.png')).toBeTruthy(); - expect(screen.getByAltText('画布图片:测试上传B.png')).toBeTruthy(); + expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy(); }); - expect(createEditorAssetMock).toHaveBeenCalledTimes(2); - expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2); - expect(createEditorAssetMock).toHaveBeenNthCalledWith( - 1, + expect(createEditorAssetMock).toHaveBeenCalledWith( expect.objectContaining({ - label: '测试上传A.png', + label: '测试上传.png', imageSrc: expect.stringMatching(/^data:image\/png;base64,/u), }), ); - expect(createEditorAssetMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - label: '测试上传B.png', - imageSrc: expect.stringMatching(/^data:image\/png;base64,/u), - }), - ); - expect(screen.getByRole('button', { name: '选择图层测试上传A.png' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '选择图层测试上传B.png' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '选择图层测试上传.png' }), + ).toBeTruthy(); + }); + + it('drops files into the asset panel only once without creating canvas layers', async () => { + render(); + + fireEvent.drop(screen.getByRole('region', { name: '项目素材' }), { + dataTransfer: { + files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })], + types: ['Files'], + }, + }); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: '添加素材拖拽.png' }), + ).toBeTruthy(); + }); + expect(createEditorAssetMock).toHaveBeenCalledTimes(1); + expect(screen.queryByAltText('画布图片:素材拖拽.png')).toBeNull(); }); it('adds an asset library image to the canvas by dragging it onto the viewport', async () => { - await renderLoadedEditor(); + render(); - const sourceAsset = screen.getByRole('button', { name: '添加抓大鹅素材' }); - const sourceAssetRow = sourceAsset.closest('.image-canvas-editor__asset-row'); + const sourceAsset = await screen.findByRole('button', { + name: '添加抓大鹅素材', + }); + const sourceAssetRow = sourceAsset.closest( + '.image-canvas-editor__asset-row', + ); const viewport = screen.getByLabelText('画布工作区'); const dataTransfer = createDataTransferStub(); @@ -1623,6 +1576,227 @@ describe('ImageCanvasEditorView', () => { expect(createEditorAssetMock).not.toHaveBeenCalled(); }); + it('blocks the browser context menu inside the editor workspace', () => { + render(); + + const editor = screen.getByRole('region', { name: '图片画布编辑器' }); + const contextMenuEvent = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + const wasNotCanceled = editor.dispatchEvent(contextMenuEvent); + + expect(wasNotCanceled).toBe(false); + expect(contextMenuEvent.defaultPrevented).toBe(true); + }); + + it('shows the blank canvas context menu with paste disabled, zoom, and fit all', () => { + render(); + + const viewport = screen.getByLabelText('画布工作区'); + fireEvent.contextMenu(viewport, { + clientX: 320, + clientY: 220, + }); + + const menu = screen.getByRole('menu', { name: '画布右键菜单' }); + expect( + (within(menu).getByRole('menuitem', { name: '粘贴' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '显示画布所有元素' }), + ).toBeTruthy(); + }); + + it('copies, cuts, and pastes layers from the context menus', () => { + render(); + + fireEvent.contextMenu( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + clientX: 510, + clientY: 330, + }, + ); + fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); + + fireEvent.contextMenu(screen.getByLabelText('画布工作区'), { + clientX: 360, + clientY: 240, + }); + const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' }); + expect( + (within(copyPasteMenu).getByRole('menuitem', { + name: '粘贴', + }) as HTMLButtonElement).disabled, + ).toBe(false); + fireEvent.click(within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' })); + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); + + fireEvent.contextMenu( + screen.getByAltText('画布图片:大鱼素材').closest('button')!, + { + clientX: 950, + clientY: 380, + }, + ); + fireEvent.click(screen.getByRole('menuitem', { name: '剪切' })); + expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull(); + + fireEvent.contextMenu(screen.getByLabelText('画布工作区'), { + clientX: 420, + clientY: 260, + }); + fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' })); + expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy(); + }); + + it('handles layer context menu duplicate, ordering, hide, lock, flip, group, ungroup, and delete', async () => { + render(); + + const firstLayer = screen + .getByAltText('画布图片:拼图素材') + .closest('button')!; + fireEvent.contextMenu(firstLayer, { clientX: 510, clientY: 330 }); + fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' })); + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); + + const copiedLayer = screen + .getAllByAltText(/画布图片:拼图素材/u)[1]! + .closest('button')!; + fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); + fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' })); + expect( + (screen.getAllByAltText(/画布图片:拼图素材/u)[1] as HTMLElement).style + .transform, + ).toBe('scale(-1, 1)'); + + fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); + fireEvent.click(screen.getByRole('menuitem', { name: '锁定' })); + await waitFor(() => { + expect(copiedLayer.className).toContain( + 'image-canvas-editor__layer--locked', + ); + }); + + fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); + fireEvent.click(screen.getByRole('menuitem', { name: '隐藏' })); + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(1); + + fireEvent.click(screen.getByRole('button', { name: '打开图层' })); + expect(screen.getByText(/已隐藏/u)).toBeTruthy(); + fireEvent.contextMenu( + screen.getByText(/已隐藏/u).closest('.image-canvas-editor__layer-row')!, + { + clientX: 80, + clientY: 220, + }, + ); + fireEvent.click(screen.getByRole('menuitem', { name: '显示' })); + expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); + + const bigFishLayer = screen + .getByAltText('画布图片:大鱼素材') + .closest('button')!; + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '置于顶层' })); + expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(2); + + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '下移一层' })); + expect(Number.parseInt(bigFishLayer.style.zIndex, 10)).toBeGreaterThan(0); + + fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); + fireEvent.pointerDown( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + button: 0, + pointerId: 181, + clientX: 520, + clientY: 380, + shiftKey: true, + }, + ); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 181, + clientX: 520, + clientY: 380, + }); + fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '创建组' })); + await waitFor(() => { + expect(screen.getAllByText(/已打组/u).length).toBeGreaterThan(0); + }); + + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '解除组' })); + await waitFor(() => { + expect(screen.queryByText(/已打组/u)).toBeNull(); + }); + + fireEvent.contextMenu(bigFishLayer, { clientX: 950, clientY: 380 }); + fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); + expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull(); + }); + + it('switches the shared sidebar between assets and layers', () => { + render(); + + const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); + expect(within(sidebar).getByText('素材')).toBeTruthy(); + expect(within(sidebar).queryByText('已生成文件')).toBeNull(); + expect(within(sidebar).queryByText('图层')).toBeNull(); + expect(screen.queryByRole('toolbar', { name: '画布主工具栏' })).toBeNull(); + expect( + screen.queryByRole('complementary', { name: '图层面板' }), + ).toBeNull(); + expect(screen.queryByRole('dialog', { name: '已生成文件' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '打开图层' })); + const layersPanel = screen.getByRole('complementary', { + name: '图片资源栏', + }); + expect( + within(layersPanel).getByRole('button', { name: '选择图层拼图素材' }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '选择图层大鱼素材' })); + + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '查看大鱼素材图片信息' }), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '打开素材' })); + + expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy(); + }); + + it('adds assets from the sidebar and supports zoom buttons', () => { + render(); + + expect( + screen.getByRole('button', { name: '当前缩放比例 100%' }).className, + ).toContain('platform-inline-option-button'); + + fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); + fireEvent.click(screen.getByRole('menuitem', { name: '放大' })); + expect( + screen.getByRole('button', { name: '当前缩放比例 116%' }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); + + expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); + expect( + screen.getByRole('complementary', { name: '图片资源栏' }), + ).toBeTruthy(); + }); + it('saves canvas layout without embedding image payloads in layer snapshots', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ @@ -1654,8 +1828,7 @@ describe('ImageCanvasEditorView', () => { await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalled(); }); - const layoutCalls = saveEditorProjectLayoutMock.mock.calls; - const lastLayout = layoutCalls.at(-1)?.[1]; + const lastLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]; expect(lastLayout.layers).toEqual( expect.arrayContaining([ @@ -1664,429 +1837,117 @@ describe('ImageCanvasEditorView', () => { }), ]), ); - }); - - it('adds an asset library image to the canvas with pointer dragging', async () => { - await renderLoadedEditor(); - - const sourceAsset = screen.getByRole('button', { name: '添加抓大鹅素材' }); - const viewport = screen.getByLabelText('画布工作区'); - vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ - x: 320, - y: 80, - top: 80, - left: 320, - right: 1120, - bottom: 680, - width: 800, - height: 600, - toJSON: () => ({}), - } as DOMRect); - - dispatchPointerEvent(sourceAsset, 'pointerdown', { - button: 0, - pointerId: 81, - clientX: 160, - clientY: 220, - }); - dispatchPointerEvent(window, 'pointermove', { - pointerId: 81, - clientX: 520, - clientY: 300, - }); - - await waitFor(() => { - expect(screen.getByText('添加到画布')).toBeTruthy(); - }); - expect(screen.getAllByText('抓大鹅素材').length).toBeGreaterThan(1); - - dispatchPointerEvent(window, 'pointerup', { - pointerId: 81, - clientX: 520, - clientY: 300, - }); - - expect(screen.queryByText('添加到画布')).toBeNull(); - expect(screen.getByAltText('画布图片:抓大鹅素材')).toBeTruthy(); - expect(createEditorProjectResourceMock).toHaveBeenCalledWith( - 'editor-project-default', - expect.objectContaining({ - imageSrc: '/creation-type-references/match3d.webp', - sourceType: 'uploaded', - }), - ); - expect(createEditorAssetMock).not.toHaveBeenCalled(); - }); - - it('shows a canvas drop overlay while dragging uploaded images over the canvas', async () => { - await renderLoadedEditor(); - - const viewport = screen.getByLabelText('画布工作区'); - fireEvent.dragOver(viewport, { - dataTransfer: { - files: [new File(['image'], '画布提示.png', { type: 'image/png' })], - types: ['Files'], - }, - }); - - expect(screen.getByText('添加到画布')).toBeTruthy(); - expect(screen.getByText('松开即可添加')).toBeTruthy(); - - fireEvent.drop(viewport, { - clientX: 430, - clientY: 260, - dataTransfer: { - files: [new File(['image'], '画布提示.png', { type: 'image/png' })], - types: ['Files'], - }, - }); - - await waitFor(() => { - expect(screen.queryByText('添加到画布')).toBeNull(); - }); - await waitFor(() => { - expect(screen.getByAltText('画布图片:画布提示.png')).toBeTruthy(); - }); - await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( - 'editor-project-default', + expect(lastLayout.layers).toEqual( + expect.arrayContaining([ expect.objectContaining({ - layers: expect.arrayContaining([ - expect.objectContaining({ - title: '画布提示.png', - resourceId: 'resource-editor-project-default-420', - sourceAssetId: 'persisted-画布提示.png', - }), - ]), + sourceAssetId: 'asset-data-heavy', }), - ); - }); - }); - - it('removes a dropped canvas upload when project resource persistence fails', async () => { - createEditorProjectResourceMock.mockRejectedValueOnce(new Error('unauthorized')); - await renderLoadedEditor(); - - const viewport = screen.getByLabelText('画布工作区'); - fireEvent.drop(viewport, { - clientX: 430, - clientY: 260, - dataTransfer: { - files: [new File(['image'], '未保存图片.png', { type: 'image/png' })], - types: ['Files'], - }, - }); - - await waitFor(() => { - expect(createEditorProjectResourceMock).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(screen.queryByAltText('画布图片:未保存图片.png')).toBeNull(); - }); - }); - - it('drops files into the asset panel only once without creating canvas layers', async () => { - await renderLoadedEditor(); - - const assetFolder = screen.getByRole('region', { name: '项目素材' }); - fireEvent.dragOver(assetFolder, { - dataTransfer: { - files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })], - types: ['Files'], - }, - }); - - expect(screen.getByText('添加到素材')).toBeTruthy(); - expect(screen.getByText('松开即可添加')).toBeTruthy(); - - fireEvent.drop(assetFolder, { - dataTransfer: { - files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })], - types: ['Files'], - }, - }); - - await waitFor(() => { - expect(screen.getByRole('button', { name: '添加素材拖拽.png' })).toBeTruthy(); - }); - expect(screen.queryByText('添加到素材')).toBeNull(); - expect(createEditorAssetMock).toHaveBeenCalledTimes(1); - expect(screen.queryByAltText('画布图片:素材拖拽.png')).toBeNull(); - }); - - it('shows the blank canvas context menu with paste disabled before copying', async () => { - await renderLoadedEditor(); - - const viewport = screen.getByLabelText('画布工作区'); - fireEvent.contextMenu(viewport, { - clientX: 320, - clientY: 220, - }); - - const menu = screen.getByRole('menu', { name: '画布右键菜单' }); - expect( - (within(menu).getByRole('menuitem', { name: '粘贴' }) as HTMLButtonElement) - .disabled, - ).toBe(true); - expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy(); - expect( - within(menu).getByRole('menuitem', { name: '显示画布所有元素' }), - ).toBeTruthy(); - }); - - it('keeps context menus inside the viewport instead of relying on internal scrollbars', async () => { - await renderLoadedEditor(); - - Object.defineProperty(window, 'innerWidth', { - configurable: true, - value: 640, - }); - Object.defineProperty(window, 'innerHeight', { - configurable: true, - value: 480, - }); - - fireEvent.contextMenu(screen.getByAltText('画布图片:拼图素材').closest('button')!, { - clientX: 630, - clientY: 470, - }); - - const menu = screen.getByRole('menu', { name: '图层右键菜单' }) as HTMLElement; - expect(Number.parseFloat(menu.style.left)).toBeLessThan(630); - expect(Number.parseFloat(menu.style.top)).toBeLessThan(470); - }); - - it('copies a layer from the target context menu and pastes it on the canvas', async () => { - await renderLoadedEditor(); - - fireEvent.contextMenu(screen.getByAltText('画布图片:拼图素材').closest('button')!, { - clientX: 510, - clientY: 330, - }); - fireEvent.click(screen.getByRole('menuitem', { name: '复制' })); - - fireEvent.contextMenu(screen.getByLabelText('画布工作区'), { - clientX: 360, - clientY: 240, - }); - const blankMenu = screen.getByRole('menu', { name: '画布右键菜单' }); - expect( - (within(blankMenu).getByRole('menuitem', { - name: '粘贴', - }) as HTMLButtonElement).disabled, - ).toBe(false); - fireEvent.click(within(blankMenu).getByRole('menuitem', { name: '粘贴' })); - - expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); - }); - - it('handles target context menu duplicate, hide, lock, flip, group, ungroup, and delete', async () => { - await renderLoadedEditor(); - - const firstLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!; - fireEvent.contextMenu(firstLayer, { clientX: 510, clientY: 330 }); - fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' })); - expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); - - const copiedLayer = screen.getAllByAltText(/画布图片:拼图素材/u)[1]!.closest('button')!; - fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); - fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' })); - expect( - (screen.getAllByAltText(/画布图片:拼图素材/u)[1] as HTMLElement).style - .transform, - ).toBe('scale(-1, 1)'); - - fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); - fireEvent.click(screen.getByRole('menuitem', { name: '锁定' })); - await waitFor(() => { - expect(copiedLayer.className).toContain('image-canvas-editor__layer--locked'); - }); - - fireEvent.contextMenu(copiedLayer, { clientX: 540, clientY: 360 }); - fireEvent.click(screen.getByRole('menuitem', { name: '隐藏' })); - expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(1); - - fireEvent.click(screen.getByRole('button', { name: '打开图层' })); - expect(screen.getByText(/已隐藏/u)).toBeTruthy(); - fireEvent.contextMenu(screen.getByText(/已隐藏/u).closest('.image-canvas-editor__layer-row')!, { - clientX: 80, - clientY: 220, - }); - fireEvent.click(screen.getByRole('menuitem', { name: '显示' })); - expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2); - - fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); - fireEvent.pointerDown(screen.getByAltText('画布图片:大鱼素材').closest('button')!, { - button: 0, - pointerId: 181, - clientX: 950, - clientY: 380, - shiftKey: true, - }); - fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { - pointerId: 181, - clientX: 950, - clientY: 380, - }); - fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' }); - fireEvent.contextMenu(screen.getByAltText('画布图片:大鱼素材').closest('button')!, { - clientX: 950, - clientY: 380, - }); - fireEvent.click(screen.getByRole('menuitem', { name: '创建组' })); - await waitFor(() => { - expect(screen.getAllByText(/已打组/u).length).toBeGreaterThan(0); - }); - - fireEvent.contextMenu(screen.getByAltText('画布图片:大鱼素材').closest('button')!, { - clientX: 950, - clientY: 380, - }); - fireEvent.click(screen.getByRole('menuitem', { name: '解除组' })); - await waitFor(() => { - expect(screen.queryByText(/已打组/u)).toBeNull(); - }); - - fireEvent.contextMenu(screen.getByAltText('画布图片:大鱼素材').closest('button')!, { - clientX: 950, - clientY: 380, - }); - fireEvent.click(screen.getByRole('menuitem', { name: '删除' })); - expect(screen.queryByAltText('画布图片:大鱼素材')).toBeNull(); - }); - - it('switches the shared sidebar between assets and layers', async () => { - await renderLoadedEditor(); - - const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); - expect(within(sidebar).getByText('素材')).toBeTruthy(); - expect(within(sidebar).queryByText('已生成文件')).toBeNull(); - expect(within(sidebar).queryByText('图层')).toBeNull(); - expect(screen.queryByRole('toolbar', { name: '画布主工具栏' })).toBeNull(); - expect(screen.queryByRole('complementary', { name: '图层面板' })).toBeNull(); - expect(screen.queryByRole('dialog', { name: '已生成文件' })).toBeNull(); - - fireEvent.click(screen.getByRole('button', { name: '打开图层' })); - const layersPanel = screen.getByRole('complementary', { name: '图片资源栏' }); - expect( - within(layersPanel).getByRole('button', { name: '选择图层拼图素材' }), - ).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '选择图层大鱼素材' })); - - expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '查看大鱼素材元数据' })).toBeNull(); - - fireEvent.click(screen.getByRole('button', { name: '打开素材' })); - - expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy(); - }); - - it('adds assets from the sidebar and supports zoom buttons', async () => { - await renderLoadedEditor(); - const user = userEvent.setup(); - - expect( - screen.getByRole('button', { name: '当前缩放比例 100%' }).className, - ).toContain('platform-inline-option-button'); - - fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); - fireEvent.click(screen.getByRole('menuitem', { name: '放大' })); - expect(screen.getByRole('button', { name: '当前缩放比例 116%' })).toBeTruthy(); - - await user.click(screen.getByRole('button', { name: '添加声浪素材' })); - - expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); - expect(screen.getByRole('complementary', { name: '图片资源栏' })).toBeTruthy(); - }); - - it('places the zoom percentage at the far right of the bottom-left panel dock', async () => { - await renderLoadedEditor(); - - const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); - const controls = within(panelToolbar).getAllByRole('button'); - - expect(controls.at(-1)?.getAttribute('aria-label')).toBe('当前缩放比例 100%'); + ]), + ); }); it('offers Lovart-style zoom menu commands', async () => { - await renderLoadedEditor(); + render(); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); expect(screen.getByRole('menu', { name: '缩放菜单' })).toBeTruthy(); - expect(screen.getByRole('menuitem', { name: '显示画布所有元素' })).toBeTruthy(); + expect( + screen.getByRole('menuitem', { name: '显示画布所有元素' }), + ).toBeTruthy(); fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' })); - expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy(); + expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' })); fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' })); - expect(screen.getByRole('button', { name: '当前缩放比例 50%' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '当前缩放比例 50%' }), + ).toBeTruthy(); }); - it('shows the Lovart-style minimap and canvas background controls', async () => { - await renderLoadedEditor(); + it('shows the Lovart-style minimap and canvas background settings panel', () => { + render(); const viewport = screen.getByLabelText('画布工作区'); const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); + const backgroundButton = within(panelToolbar).getByRole('button', { + name: '画布背景色', + }); expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy(); + expect(backgroundButton.className).toContain('platform-icon-button'); expect( - within(panelToolbar).getByRole('button', { name: '画布背景色' }).className, - ).toContain('platform-icon-button'); - expect(within(panelToolbar).getByRole('button', { name: '切换小地图' })).toBeTruthy(); + within(panelToolbar).getByRole('button', { name: '切换小地图' }), + ).toBeTruthy(); - fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' })); - expect(screen.getByRole('dialog', { name: '画布背景设置' })).toBeTruthy(); - expect(screen.getByText('画布背景色')).toBeTruthy(); - expect(screen.getByRole('button', { name: '关闭画布背景设置' })).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '切换画布背景色为默认浅灰' })); - - expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(248, 250, 252)'); - - fireEvent.change(screen.getByLabelText('自定义画布背景色'), { - target: { value: '#ffffff' }, + fireEvent.click(backgroundButton); + const settingsPanel = screen.getByRole('dialog', { + name: '画布背景设置', }); - expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(255, 255, 255)'); + expect(within(settingsPanel).getByText('画布背景')).toBeTruthy(); - const hexInput = screen.getByLabelText('画布背景十六进制颜色') as HTMLInputElement; - fireEvent.change(hexInput, { - target: { value: '#abc' }, - }); - expect(hexInput.value).toBe('AABBCC'); - expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(170, 187, 204)'); + fireEvent.click(within(settingsPanel).getByRole('button', { name: '暖灰' })); - fireEvent.change(hexInput, { - target: { value: 'not-a-color' }, - }); - expect(hexInput.value).toBe('not-a-color'); - expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(170, 187, 204)'); + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(243, 240, 234)', + ); - fireEvent.click(screen.getByRole('button', { name: '切换画布背景色为默认浅灰' })); - expect(hexInput.value).toBe('F8FAFC'); - expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(248, 250, 252)'); + fireEvent.change( + within(settingsPanel).getByLabelText('自定义画布背景色'), + { + target: { value: '#ffffff' }, + }, + ); + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(255, 255, 255)', + ); - fireEvent.click(screen.getByRole('button', { name: '关闭画布背景设置' })); - expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull(); + const hexInput = within(settingsPanel).getByLabelText( + '画布背景十六进制颜色', + ); + fireEvent.change(hexInput, { target: { value: '#abc' } }); + expect((hexInput as HTMLInputElement).value).toBe('#aabbcc'); + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(170, 187, 204)', + ); + + fireEvent.change(hexInput, { target: { value: '#not-a-color' } }); + expect((hexInput as HTMLInputElement).value).toBe('#not-a-color'); + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(170, 187, 204)', + ); + + fireEvent.click( + within(settingsPanel).getByRole('button', { name: '恢复默认' }), + ); + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(248, 250, 252)', + ); - fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' })); fireEvent.keyDown(window, { key: 'Escape' }); - expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull(); + expect( + screen.queryByRole('dialog', { name: '画布背景设置' }), + ).toBeNull(); - fireEvent.click(within(panelToolbar).getByRole('button', { name: '切换小地图' })); + fireEvent.click( + within(panelToolbar).getByRole('button', { name: '切换小地图' }), + ); expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull(); }); - it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', async () => { - await renderLoadedEditor(); + it('uses normal wheel for vertical canvas scroll and ctrl wheel for zoom', () => { + render(); const viewport = screen.getByLabelText('画布工作区'); - expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '当前缩放比例 100%' }), + ).toBeTruthy(); fireEvent.wheel(viewport, { deltaY: 120, clientX: 400, clientY: 280 }); - expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '当前缩放比例 100%' }), + ).toBeTruthy(); fireEvent.wheel(viewport, { deltaY: -120, @@ -2094,7 +1955,9 @@ describe('ImageCanvasEditorView', () => { clientX: 400, clientY: 280, }); - expect(screen.getByRole('button', { name: '当前缩放比例 110%' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '当前缩放比例 110%' }), + ).toBeTruthy(); const ctrlWheelEvent = new WheelEvent('wheel', { bubbles: true, @@ -2109,10 +1972,14 @@ describe('ImageCanvasEditorView', () => { }); it('selects multiple canvas layers with shift click', async () => { - await renderLoadedEditor(); + render(); - const firstLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!; - const secondLayer = screen.getByAltText('画布图片:大鱼素材').closest('button')!; + const firstLayer = screen + .getByAltText('画布图片:拼图素材') + .closest('button')!; + const secondLayer = screen + .getByAltText('画布图片:大鱼素材') + .closest('button')!; fireEvent.pointerDown(firstLayer, { button: 0, @@ -2155,8 +2022,8 @@ describe('ImageCanvasEditorView', () => { }); }); - it('drags the minimap to move the canvas viewport', async () => { - await renderLoadedEditor(); + it('drags the minimap to move the canvas viewport', () => { + render(); const minimap = screen.getByRole('button', { name: '画布小地图' }); vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({ @@ -2171,29 +2038,21 @@ describe('ImageCanvasEditorView', () => { toJSON: () => ({}), }); dispatchPointerEvent(minimap, 'pointerdown', { - button: 0, - pointerId: 71, - clientX: 60, - clientY: 42, - }); - const world = screen - .getByLabelText('画布工作区') - .querySelector('.image-canvas-editor__world') as HTMLElement; - const transformAfterDown = world.style.transform; - - dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { button: 0, pointerId: 71, clientX: 120, clientY: 72, }); - expect(world.style.transform).not.toBe(transformAfterDown); + const firstLayer = screen + .getByAltText('画布图片:拼图素材') + .closest('button')!; + expect(Number.parseFloat((firstLayer as HTMLElement).style.left)).toBe(470); expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy(); }); - it('keeps minimap drag direction stable after pausing and reversing', async () => { - await renderLoadedEditor(); + it('keeps minimap drag direction stable after pausing and reversing', () => { + render(); const minimap = screen.getByRole('button', { name: '画布小地图' }); vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({ @@ -2240,15 +2099,18 @@ describe('ImageCanvasEditorView', () => { }); it('persists layer groups in the canvas layer snapshot', async () => { - await renderLoadedEditor(); + render(); fireEvent.click(screen.getByRole('button', { name: '打开图层' })); - fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { - button: 0, - pointerId: 90, - clientX: 120, - clientY: 120, - }); + fireEvent.pointerDown( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + button: 0, + pointerId: 90, + clientX: 120, + clientY: 120, + }, + ); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 90, clientX: 120, @@ -2260,13 +2122,16 @@ describe('ImageCanvasEditorView', () => { ).toContain('image-canvas-editor__layer--selected'); }); fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' }); - fireEvent.pointerDown(screen.getByAltText('画布图片:大鱼素材').closest('button')!, { - button: 0, - pointerId: 91, - clientX: 520, - clientY: 180, - shiftKey: true, - }); + fireEvent.pointerDown( + screen.getByAltText('画布图片:大鱼素材').closest('button')!, + { + button: 0, + pointerId: 91, + clientX: 520, + clientY: 180, + shiftKey: true, + }, + ); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 91, clientX: 520, @@ -2317,10 +2182,12 @@ describe('ImageCanvasEditorView', () => { provider: 'VectorEngine', taskId: 'editor-real-task-1', }); - await renderLoadedEditor(); + render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); - fireEvent.click(within(bottomToolbar).getByRole('button', { name: '生成工具' })); + fireEvent.click( + within(bottomToolbar).getByRole('button', { name: '生成工具' }), + ); const generateDialog = screen.getByRole('dialog', { name: '生成图片' }); const initialComposerTop = Number.parseFloat( @@ -2329,10 +2196,12 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); expect(within(generateDialog).getByText('参考图')).toBeTruthy(); expect( - within(generateDialog).getByRole('button', { name: '添加参考图' }).className, + within(generateDialog).getByRole('button', { name: '添加参考图' }) + .className, ).toContain('bg-white/94'); expect( - within(generateDialog).getByRole('button', { name: '添加参考图' }).className, + within(generateDialog).getByRole('button', { name: '添加参考图' }) + .className, ).toContain('image-canvas-editor__generation-ref'); const generatePrompt = screen.getByLabelText('生成提示词'); expect(generatePrompt.className).toContain('platform-text-field'); @@ -2349,18 +2218,20 @@ describe('ImageCanvasEditorView', () => { name: '生成模型 GPT Image', }).className, ).toContain('platform-inline-option-button'); - expect(within(generateDialog).getByRole('button', { name: '生成' }).className).toContain( - 'platform-button', - ); - expect(within(generateDialog).getByRole('button', { name: '生成' }).className).toContain( - 'image-canvas-editor__generation-submit', - ); + expect( + within(generateDialog).getByRole('button', { name: '生成' }).className, + ).toContain('platform-button'); + expect( + within(generateDialog).getByRole('button', { name: '生成' }).className, + ).toContain('image-canvas-editor__generation-submit'); expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张明亮的拼图主视觉' }, }); - fireEvent.click(within(generateDialog).getByRole('button', { name: '生成' })); + fireEvent.click( + within(generateDialog).getByRole('button', { name: '生成' }), + ); expect(screen.getByRole('status').textContent).toContain('生成中'); expect(generateEditorImageMock).toHaveBeenCalledWith({ @@ -2370,52 +2241,38 @@ describe('ImageCanvasEditorView', () => { await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); - await waitFor(() => { - expect(createEditorAssetMock).toHaveBeenCalledWith( - expect.objectContaining({ - folderId: 'project', - label: expect.stringMatching(/^生成图片/u), - imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', - sourceType: 'generated', - prompt: '一张明亮的拼图主视觉', - model: 'gpt-image-2', - provider: 'VectorEngine', - taskId: 'editor-real-task-1', - }), - ); + const generatedLayer = screen + .getByAltText(/画布图片:生成图片/) + .closest('button')!; + const anchoredGenerateDialog = screen.getByRole('dialog', { + name: '生成图片', }); - fireEvent.click(screen.getByRole('button', { name: '打开素材' })); - expect(screen.getByRole('button', { name: /添加生成图片/u })).toBeTruthy(); - await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( - 'editor-project-default', - expect.objectContaining({ - layers: expect.arrayContaining([ - expect.objectContaining({ - title: expect.stringMatching(/^生成图片/u), - sourceType: 'generated', - sourceAssetId: 'persisted-生成图片 3', - resourceId: 'resource-editor-project-default-1024', - }), - ]), - }), - ); - }); - fireEvent.click(screen.getByRole('button', { name: '打开图层' })); - const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!; - const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' }); expect(anchoredGenerateDialog).toBeTruthy(); expect( Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), - ).toBeGreaterThan(Number.parseFloat((generatedLayer as HTMLElement).style.top)); + ).toBeGreaterThan( + Number.parseFloat((generatedLayer as HTMLElement).style.top), + ); expect( Number.parseFloat((generatedLayer as HTMLElement).style.top), ).toBeLessThan(initialComposerTop); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); const metadataButtons = screen.getAllByRole('button', { - name: /查看生成图片 .*元数据/, + name: /查看生成图片 .*图片信息/, }); expect(metadataButtons[0]).toBeTruthy(); + fireEvent.click(metadataButtons[0]!); + + const infoPanel = screen.getByRole('dialog', { + name: /生成图片 .*图片信息/, + }); + expect(within(infoPanel).queryByText('Prompt')).toBeNull(); + expect( + within(infoPanel).queryByRole('button', { name: '复制Prompt' }), + ).toBeNull(); + expect(within(infoPanel).getByText('生成输入')).toBeTruthy(); + expect(within(infoPanel).getByText('生成提示词')).toBeTruthy(); + expect(within(infoPanel).getByText('一张明亮的拼图主视觉')).toBeTruthy(); }); it('drags the generation placeholder and places the generated layer there', async () => { @@ -2430,14 +2287,15 @@ describe('ImageCanvasEditorView', () => { provider: 'VectorEngine', taskId: 'editor-drag-frame-1', }); - await renderLoadedEditor(); + render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); const initialComposerTop = Number.parseFloat( - (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style.top, + (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style + .top, ); const frame = screen.getByLabelText('图像生成占位图'); dispatchPointerEvent(frame, 'pointerdown', { @@ -2457,9 +2315,17 @@ describe('ImageCanvasEditorView', () => { clientY: 342, }); const draggedComposerTop = Number.parseFloat( - (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style.top, + (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style + .top, ); expect(draggedComposerTop).toBeGreaterThan(initialComposerTop); + const draggedFrame = screen.getByLabelText('图像生成占位图') as HTMLElement; + const draggedFrameCenterX = + Number.parseFloat(draggedFrame.style.left) + + Number.parseFloat(draggedFrame.style.width) / 2; + const draggedFrameCenterY = + Number.parseFloat(draggedFrame.style.top) + + Number.parseFloat(draggedFrame.style.height) / 2; fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '拖拽后的生成图' }, }); @@ -2469,29 +2335,132 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); - const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!; - const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' }); + const generatedLayer = screen + .getByAltText(/画布图片:生成图片/) + .closest('button')!; + const anchoredGenerateDialog = screen.getByRole('dialog', { + name: '生成图片', + }); expect(anchoredGenerateDialog).toBeTruthy(); expect( Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), - ).toBeGreaterThan(Number.parseFloat((generatedLayer as HTMLElement).style.top)); + ).toBeGreaterThan( + Number.parseFloat((generatedLayer as HTMLElement).style.top), + ); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); - expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(300); - expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(180); + expect( + Number.parseFloat((generatedLayer as HTMLElement).style.left) + + Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2, + ).toBeCloseTo(draggedFrameCenterX, 1); + expect( + Number.parseFloat((generatedLayer as HTMLElement).style.top) + + Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2, + ).toBeCloseTo(draggedFrameCenterY, 1); }); - it('hides the generation composer when selecting another image but keeps the placeholder', async () => { - await renderLoadedEditor(); + it('keeps the generation placeholder draggable while the image is generating', async () => { + let resolveGeneration!: (value: unknown) => void; + generateEditorImageMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveGeneration = resolve; + }), + ); + render(); + await waitFor(() => { + expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); + }); + + fireEvent.click(screen.getByRole('button', { name: '生成工具' })); + fireEvent.change(screen.getByLabelText('生成提示词'), { + target: { value: '生成中继续拖动的图片' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + const frame = screen.getByLabelText('图像生成占位图'); + expect(frame.className).toContain( + 'image-canvas-editor__generation-frame--generating', + ); + const initialLeft = Number.parseFloat((frame as HTMLElement).style.left); + const initialTop = Number.parseFloat((frame as HTMLElement).style.top); + + dispatchPointerEvent(frame, 'pointerdown', { + button: 0, + pointerId: 67, + clientX: 500, + clientY: 260, + }); + dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { + pointerId: 67, + clientX: 620, + clientY: 360, + }); + dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { + pointerId: 67, + clientX: 620, + clientY: 360, + }); + + const draggedFrame = screen.getByLabelText('图像生成占位图'); + expect( + Number.parseFloat((draggedFrame as HTMLElement).style.left), + ).toBeGreaterThan(initialLeft); + expect( + Number.parseFloat((draggedFrame as HTMLElement).style.top), + ).toBeGreaterThan(initialTop); + + await act(async () => { + resolveGeneration({ + imageSrc: 'data:image/png;base64,Z2VuZXJhdGluZy1kcmFn', + width: 1024, + height: 1024, + sourceType: 'generated', + prompt: '生成中继续拖动的图片', + actualPrompt: '生成中继续拖动的图片', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-generating-drag-1', + }); + }); + + await waitFor(() => { + expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); + }); + const generatedLayer = screen + .getByAltText(/画布图片:生成图片/) + .closest('button')!; + expect( + Number.parseFloat((generatedLayer as HTMLElement).style.left) + + Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2, + ).toBeCloseTo( + Number.parseFloat((draggedFrame as HTMLElement).style.left) + + Number.parseFloat((draggedFrame as HTMLElement).style.width) / 2, + 1, + ); + expect( + Number.parseFloat((generatedLayer as HTMLElement).style.top) + + Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2, + ).toBeCloseTo( + Number.parseFloat((draggedFrame as HTMLElement).style.top) + + Number.parseFloat((draggedFrame as HTMLElement).style.height) / 2, + 1, + ); + }); + + it('hides the generation composer when selecting another image but keeps the placeholder', () => { + render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); - fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { - button: 0, - pointerId: 62, - clientX: 120, - clientY: 120, - }); + fireEvent.pointerDown( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + button: 0, + pointerId: 62, + clientX: 120, + clientY: 120, + }, + ); expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); @@ -2506,8 +2475,8 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); }); - it('keeps the generation composer when clicking the canvas outside generation controls', async () => { - await renderLoadedEditor(); + it('hides the generation composer when clicking the canvas outside generation controls', () => { + render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); @@ -2519,12 +2488,12 @@ describe('ImageCanvasEditorView', () => { clientY: 180, }); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); }); - it('closes the generation composer without removing the placeholder frame', async () => { - await renderLoadedEditor(); + it('closes the generation composer without removing the placeholder frame', () => { + render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' })); @@ -2534,8 +2503,10 @@ describe('ImageCanvasEditorView', () => { }); it('shows generation errors instead of falling back to mock images', async () => { - generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置')); - await renderLoadedEditor(); + generateEditorImageMock.mockRejectedValueOnce( + new Error('VectorEngine 未配置'), + ); + render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { @@ -2546,7 +2517,9 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByRole('status').textContent).toContain('生成中'); await waitFor(() => { - expect(screen.getByRole('alert').textContent).toContain('VectorEngine 未配置'); + expect(screen.getByRole('alert').textContent).toContain( + 'VectorEngine 未配置', + ); }); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull(); @@ -2560,7 +2533,7 @@ describe('ImageCanvasEditorView', () => { code: 'UNAUTHORIZED', }), ); - await renderLoadedEditor(); + render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { @@ -2576,14 +2549,1562 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByText(/requestId/u)).toBeNull(); }); - it('switches tools and restores the previous tool after holding Space', async () => { - const user = userEvent.setup(); - await renderLoadedEditor(); + it('hides image generation setting panels after generation starts while keeping the preview frame visible', async () => { + const cases = [ + { + open: () => { + fireEvent.click(screen.getByRole('button', { name: '生成工具' })); + fireEvent.change(screen.getByLabelText('生成提示词'), { + target: { value: '生成中的普通图片' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + }, + dialogName: '生成图片', + frameLabel: '图像生成占位图', + }, + { + open: () => { + fireEvent.click( + within( + screen.getByRole('toolbar', { name: 'AI画布工具栏' }), + ).getByRole('button', { name: '生成规范' }), + ); + fireEvent.click( + within( + screen.getByRole('menu', { name: '生成规范类型' }), + ).getByRole('menuitem', { name: '自定义规范' }), + ); + fireEvent.change(screen.getByLabelText('自定义规范提示词'), { + target: { value: '生成中的自定义规范图' }, + }); + fireEvent.click( + within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( + 'button', + { name: '提交生成规范' }, + ), + ); + }, + dialogName: '生成规范', + frameLabel: '规范生成占位图', + }, + { + open: () => { + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + fireEvent.change(screen.getByLabelText('角色设定'), { + target: { value: '生成中的角色形象' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + }, + dialogName: '生成角色形象', + frameLabel: '角色生成占位图', + }, + ] as const; + + for (const testCase of cases) { + generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); + const { unmount } = render(); + await waitFor(() => { + expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); + }); + + testCase.open(); + + expect( + screen.queryByRole('dialog', { name: testCase.dialogName }), + ).toBeNull(); + const frame = screen.getByLabelText(testCase.frameLabel); + expect(frame.className).toContain( + 'image-canvas-editor__generation-frame--generating', + ); + expect(within(frame).getByRole('status').textContent).toContain('生成中'); + + unmount(); + } + }); + + it('hides the icon material panel after generation starts while keeping the icon preview frame visible', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-icons-generating', + title: '图标素材生成中画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-icon-spec-generating', + resourceId: 'resource-icon-spec-generating', + title: '清爽按钮图标规范', + src: 'data:image/png;base64,icon-spec-generating', + x: 80, + y: 80, + width: 160, + height: 160, + originalWidth: 512, + originalHeight: 512, + zIndex: 10, + sourceType: 'generated', + assetKind: 'icon-spec', + }, + ], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + generateEditorIconSpritesheetMock.mockReturnValueOnce( + new Promise(() => undefined), + ); + render(); + + await screen.findByAltText('画布图片:清爽按钮图标规范'); + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + fireEvent.click( + within(iconPanel).getByRole('button', { name: '图标素材规范' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '图标素材规范来源' })).getByRole( + 'menuitem', + { name: '从画布中选择' }, + ), + ); + fireEvent.pointerDown( + screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, + { + button: 0, + pointerId: 1260, + clientX: 120, + clientY: 120, + }, + ); + fireEvent.click( + within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( + 'button', + { name: '生成' }, + ), + ); + + expect(screen.queryByRole('dialog', { name: '生成图标素材' })).toBeNull(); + const frame = screen.getByLabelText('图标素材生成占位图'); + expect(frame.className).toContain( + 'image-canvas-editor__generation-frame--generating', + ); + expect(within(frame).getByRole('status').textContent).toContain('生成中'); + }); + + it('opens character spec generation form and creates a labeled spec layer', async () => { + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,c3BlYy1yb2xl', + width: 2048, + height: 1152, + sourceType: 'generated', + prompt: '角色规范提示词', + actualPrompt: '角色规范提示词', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-spec-role-1', + }); + render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); - const selectTool = within(bottomToolbar).getByRole('button', { name: '选择工具' }); - const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' }); - const handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具' }); + const generationToolLabels = within(bottomToolbar) + .getAllByRole('button') + .filter((button) => button.getAttribute('aria-label')?.startsWith('生成')) + .map((button) => button.getAttribute('aria-label')); + expect(generationToolLabels).toContain('生成工具'); + expect(generationToolLabels).toContain('生成规范'); + fireEvent.click( + within(bottomToolbar).getByRole('button', { name: '生成规范' }), + ); + + const specMenu = screen.getByRole('menu', { name: '生成规范类型' }); + expect( + within(specMenu).getByRole('menuitem', { name: '角色形象规范' }), + ).toBeTruthy(); + expect( + within(specMenu).getByRole('menuitem', { name: 'UI素材规范' }), + ).toBeTruthy(); + expect( + within(specMenu).getByRole('menuitem', { name: '自定义规范' }), + ).toBeTruthy(); + + fireEvent.click( + within(specMenu).getByRole('menuitem', { name: '角色形象规范' }), + ); + + const specDialog = screen.getByRole('dialog', { name: '生成规范' }); + expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); + expect(screen.getByText('2048 x 1152')).toBeTruthy(); + expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( + '战棋类RPG玩法', + ); + expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( + '像素风', + ); + expect((screen.getByLabelText('头身比') as HTMLSelectElement).value).toBe( + '3', + ); + expect((screen.getByLabelText('角色视角') as HTMLInputElement).value).toBe( + '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', + ); + expect( + within(specDialog).getByRole('button', { name: '提交生成规范' }) + .textContent, + ).toContain('消耗5泥点'); + + fireEvent.change(screen.getByLabelText('玩法设定'), { + target: { value: '平台跳跃玩法' }, + }); + fireEvent.change(screen.getByLabelText('美术风格'), { + target: { value: '低多边形卡通' }, + }); + fireEvent.change(screen.getByLabelText('头身比'), { + target: { value: '4' }, + }); + fireEvent.change(screen.getByLabelText('角色视角'), { + target: { value: '左向三分之二侧身站姿' }, + }); + fireEvent.click( + within(specDialog).getByRole('button', { name: '提交生成规范' }), + ); + + expect(generateEditorImageMock).toHaveBeenCalledWith({ + kind: 'spec', + model: 'gpt-image-2', + size: '2048x1152', + prompt: expect.stringContaining('玩法设计:平台跳跃玩法'), + }); + const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; + expect(prompt).toContain('生成2D 角色美术视觉规范设定图'); + expect(prompt).toContain('美术风格:低多边形卡通'); + expect(prompt).toContain('头身比:4'); + expect(prompt).toContain('视角要求:左向三分之二侧身站姿'); + + await waitFor(() => { + expect(screen.getByAltText(/画布图片:角色形象规范/)).toBeTruthy(); + }); + expect(screen.getByText('规范')).toBeTruthy(); + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + sourceType: 'generated', + width: 2048, + height: 1152, + }), + ); + }); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + title: expect.stringMatching(/角色形象规范/u), + assetKind: 'spec', + }), + ]), + }), + ); + }); + }); + + it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => { + render(); + await screen.findByAltText('画布图片:拼图素材'); + + fireEvent.click( + within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( + 'button', + { name: '生成规范' }, + ), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' })); + + const characterSpecDialog = screen.getByRole('dialog', { + name: '生成规范', + }); + ['玩法设定', '美术风格', '头身比', '角色视角'].forEach((title) => { + expect(within(characterSpecDialog).getByText(title)).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + + const iconSpritesheetPanel = screen.getByRole('dialog', { + name: '生成图标素材', + }); + expect( + within(iconSpritesheetPanel).getByRole('button', { + name: '图标素材规范', + }), + ).toBeTruthy(); + expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy(); + expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy(); + expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy(); + expect(within(iconSpritesheetPanel).getByText('模型')).toBeTruthy(); + + fireEvent.click( + within(iconSpritesheetPanel).getByRole('button', { + name: '图标素材规范', + }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' })); + + const iconSpecDialog = screen.getByRole('dialog', { name: '生成规范' }); + ['玩法设定', '美术风格'].forEach((title) => { + expect(within(iconSpecDialog).getByText(title)).toBeTruthy(); + }); + }); + + it('defaults character and icon generation to nanobanana2 model options', async () => { + render(); + await screen.findByAltText('画布图片:拼图素材'); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + const characterPanel = screen.getByRole('dialog', { + name: '生成角色形象', + }); + expect(within(characterPanel).getByText('画面比例')).toBeTruthy(); + expect(within(characterPanel).getByText('模型')).toBeTruthy(); + expect( + within(characterPanel).getByRole('button', { name: '1:1' }), + ).toBeTruthy(); + expect( + within(characterPanel).getByRole('button', { name: 'GPT Image' }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect(within(iconPanel).getByText('模型')).toBeTruthy(); + expect( + within(iconPanel).getByRole('button', { name: 'nanobanana2' }), + ).toBeTruthy(); + }); + + it('submits character generation without legacy dimension options', async () => { + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,character-model-options', + width: 1024, + height: 1536, + sourceType: 'generated', + prompt: '高个子游侠', + actualPrompt: '高个子游侠', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'character-model-options-1', + }); + render(); + await screen.findByAltText('画布图片:拼图素材'); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + const characterPanel = screen.getByRole('dialog', { + name: '生成角色形象', + }); + fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { + target: { value: '高个子游侠' }, + }); + fireEvent.click(within(characterPanel).getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'character', + prompt: '高个子游侠', + }), + ); + }); + expect( + generateEditorImageMock.mock.calls[0]?.[0], + ).not.toEqual( + expect.objectContaining({ + aspectRatio: expect.any(String), + imageSize: expect.any(String), + }), + ); + }); + + it('keeps the bottom AI toolbar visible while generation panels are open', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + + expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); + expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); + }); + + it('keeps existing generation placeholders when another bottom generation object is created', async () => { + render(); + await act(async () => {}); + + const bottomToolbar = screen.getByRole('toolbar', { + name: 'AI画布工具栏', + }); + fireEvent.click( + within(bottomToolbar).getByRole('button', { name: '生成规范' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' })); + + expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); + expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + + expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); + expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); + expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); + + fireEvent.pointerDown(screen.getByLabelText('规范生成占位图'), { + button: 0, + pointerId: 1701, + clientX: 180, + clientY: 180, + }); + + expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy(); + expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); + }); + + it('keeps archived generation logic using the latest placeholder when another object is active', async () => { + let resolveGeneration!: (value: unknown) => void; + generateEditorImageMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveGeneration = resolve; + }), + ); + render(); + await waitFor(() => { + expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); + }); + + fireEvent.click(screen.getByRole('button', { name: '生成工具' })); + fireEvent.change(screen.getByLabelText('生成提示词'), { + target: { value: '生成中切换后仍保留位置' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + const originalFrame = screen.getByLabelText('图像生成占位图'); + const originalLeft = Number.parseFloat( + (originalFrame as HTMLElement).style.left, + ); + const originalTop = Number.parseFloat( + (originalFrame as HTMLElement).style.top, + ); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + const characterFrame = screen.getByLabelText('角色生成占位图'); + expect(characterFrame).toBeTruthy(); + + dispatchPointerEvent(screen.getByLabelText('图像生成占位图'), 'pointerdown', { + button: 0, + pointerId: 1702, + clientX: 500, + clientY: 260, + }); + dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { + pointerId: 1702, + clientX: 650, + clientY: 390, + }); + dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { + pointerId: 1702, + clientX: 650, + clientY: 390, + }); + const movedFrame = screen.getByLabelText('图像生成占位图'); + const movedLeft = Number.parseFloat( + (movedFrame as HTMLElement).style.left, + ); + const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top); + expect(movedLeft).toBeGreaterThan(originalLeft); + expect(movedTop).toBeGreaterThan(originalTop); + + dispatchPointerEvent(characterFrame, 'pointerdown', { + button: 0, + pointerId: 1703, + clientX: 360, + clientY: 240, + }); + dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { + pointerId: 1703, + clientX: 360, + clientY: 240, + }); + expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); + + await act(async () => { + resolveGeneration({ + imageSrc: 'data:image/png;base64,YXJjaGl2ZWQtbG9naWM=', + width: 1024, + height: 1024, + sourceType: 'generated', + prompt: '生成中切换后仍保留位置', + actualPrompt: '生成中切换后仍保留位置', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-archived-generation-1', + }); + }); + + await waitFor(() => { + expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); + }); + const generatedLayer = screen + .getByAltText(/画布图片:生成图片/) + .closest('button') as HTMLElement; + const expectedLayerLeft = + movedLeft + Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - 512; + const expectedLayerTop = + movedTop + Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - 512; + expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo( + expectedLayerLeft, + 1, + ); + expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo( + expectedLayerTop, + 1, + ); + expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); + }); + + it('renders editor popup menus outside clipped local containers', () => { + render(); + + const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); + fireEvent.click( + within(bottomToolbar).getByRole('button', { name: '生成规范' }), + ); + const specMenu = screen.getByRole('menu', { name: '生成规范类型' }); + + expect(bottomToolbar.contains(specMenu)).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); + fireEvent.click( + within(characterPanel).getByRole('button', { name: '角色形象规范' }), + ); + const referenceRow = characterPanel.querySelector( + '.image-canvas-editor__character-reference-row', + ); + const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' }); + + expect(referenceRow?.contains(sourceMenu)).toBe(false); + }); + + it('uses Lovart-style reference tiles in the character generation panel', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); + const specTile = within(characterPanel).getByRole('button', { + name: '角色形象规范', + }); + const uploadTile = within(characterPanel).getByRole('button', { + name: '上传常规参考图', + }); + + expect(specTile.className).toContain('image-canvas-editor__reference-tile'); + expect(uploadTile.className).toContain( + 'image-canvas-editor__reference-tile', + ); + expect( + specTile.querySelector('.image-canvas-editor__reference-tile-visual'), + ).toBeTruthy(); + expect( + uploadTile.querySelector('.image-canvas-editor__reference-tile-visual'), + ).toBeTruthy(); + }); + + it('expands the icon panel width as new description items are added', async () => { + render(); + + await waitFor(() => { + expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); + }); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(52.8, 1); + expect( + iconPanel.querySelector('.image-canvas-editor__icon-description-list'), + ).toBeTruthy(); + expect( + iconPanel.querySelector('.image-canvas-editor__icon-description-card'), + ).toBeTruthy(); + expect( + iconPanel.querySelector('.image-canvas-editor__icon-spec-card'), + ).toBeTruthy(); + + fireEvent.click(within(iconPanel).getByRole('button', { name: '添加素材描述' })); + + expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1); + expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7); + }); + + it('hides the active generation panel and clears image selection after canvas background focus', async () => { + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=', + width: 1024, + height: 1024, + sourceType: 'generated', + prompt: '发光蘑菇角色', + actualPrompt: '发光蘑菇角色', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-focus-clear-1', + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成工具' })); + fireEvent.change(screen.getByLabelText('生成提示词'), { + target: { value: '发光蘑菇角色' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + const generatedImage = await screen.findByAltText(/画布图片:生成图片/u); + const generatedLayerButton = generatedImage.closest('button')!; + expect(generatedLayerButton.className).toContain( + 'image-canvas-editor__layer--selected', + ); + expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); + + fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { + button: 0, + pointerId: 261, + clientX: 40, + clientY: 40, + }); + + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); + expect(generatedLayerButton.className).not.toContain( + 'image-canvas-editor__layer--selected', + ); + }); + + it('hides a newly created placeholder panel after canvas background focus', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); + expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); + + fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { + button: 0, + pointerId: 262, + clientX: 40, + clientY: 40, + }); + + expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); + expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); + }); + + it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => { + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,c3BlYy11aQ==', + width: 2048, + height: 1152, + sourceType: 'generated', + prompt: 'UI规范提示词', + actualPrompt: 'UI规范提示词', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-spec-ui-1', + }); + render(); + + fireEvent.click( + within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( + 'button', + { name: '生成规范' }, + ), + ); + fireEvent.click(screen.getByRole('menuitem', { name: 'UI素材规范' })); + + expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( + '抓娃娃题材的抓大鹅玩法', + ); + expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( + '毛茸茸', + ); + fireEvent.change(screen.getByLabelText('玩法设定'), { + target: { value: '消除类派对玩法' }, + }); + fireEvent.change(screen.getByLabelText('美术风格'), { + target: { value: '糖果玻璃拟物' }, + }); + fireEvent.click( + within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( + 'button', + { name: '提交生成规范' }, + ), + ); + + expect(generateEditorImageMock).toHaveBeenCalledWith({ + kind: 'spec', + model: 'gpt-image-2', + size: '2048x1152', + prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'), + }); + const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; + expect(prompt).toContain('玩法设定:消除类派对玩法'); + expect(prompt).toContain('美术风格:糖果玻璃拟物'); + + await waitFor(() => { + expect(screen.getByAltText(/画布图片:UI素材规范/)).toBeTruthy(); + }); + expect(screen.getByText('规范')).toBeTruthy(); + }); + + it('uses the custom spec prompt without template rewriting', async () => { + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,c3BlYy1jdXN0b20=', + width: 2048, + height: 1152, + sourceType: 'generated', + prompt: '自定义规范提示词', + actualPrompt: '自定义规范提示词', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-spec-custom-1', + }); + render(); + + fireEvent.click( + within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( + 'button', + { name: '生成规范' }, + ), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' })); + fireEvent.change(screen.getByLabelText('自定义规范提示词'), { + target: { value: ' 生成一张武器图标规范展板 ' }, + }); + fireEvent.click( + within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( + 'button', + { name: '提交生成规范' }, + ), + ); + + expect(generateEditorImageMock).toHaveBeenCalledWith({ + kind: 'spec', + model: 'gpt-image-2', + size: '2048x1152', + prompt: '生成一张武器图标规范展板', + }); + await waitFor(() => { + expect(screen.getByAltText(/画布图片:自定义规范/)).toBeTruthy(); + }); + expect(screen.getByText('规范')).toBeTruthy(); + }); + + it('supports character generation from a picked canvas spec and numbered references', async () => { + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,Y2hhcmFjdGVy', + objectKey: + 'generated-character-drafts/editor/character-images/editor-character-1/image.png', + assetObjectId: 'asset-object-editor-character-1', + width: 2048, + height: 2048, + sourceType: 'generated', + prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', + actualPrompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-character-1', + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + + const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); + expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); + expect( + within(characterPanel).getByRole('button', { name: '角色形象规范' }), + ).toBeTruthy(); + + fireEvent.click( + within(characterPanel).getByRole('button', { name: '角色形象规范' }), + ); + const specSourceMenu = screen.getByRole('menu', { + name: '角色形象规范来源', + }); + fireEvent.click( + within(specSourceMenu).getByRole('menuitem', { name: '从画布中选择' }), + ); + expect( + screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), + ).toBeTruthy(); + + fireEvent.pointerDown( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + button: 0, + pointerId: 170, + clientX: 120, + clientY: 120, + }, + ); + expect(within(characterPanel).getByText('拼图素材')).toBeTruthy(); + expect( + screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), + ).toBeNull(); + + fireEvent.click( + within(characterPanel).getByRole('button', { name: '上传常规参考图' }), + ); + await userEvent.upload( + screen.getByLabelText('上传图片文件'), + new File(['reference'], '常规参考.png', { type: 'image/png' }), + ); + await waitFor(() => { + expect(within(characterPanel).getByText('1')).toBeTruthy(); + }); + + fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { + target: { value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。' }, + }); + fireEvent.click( + within(characterPanel).getByRole('button', { name: '生成' }), + ); + + expect(generateEditorImageMock).toHaveBeenCalledWith({ + kind: 'character', + prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', + referenceImageSrcs: [ + '/creation-type-references/puzzle.webp', + expect.stringMatching(/^data:image\/png;base64,/u), + ], + }); + + await waitFor(() => { + expect(screen.getByAltText(/画布图片:角色形象/u)).toBeTruthy(); + }); + expect(screen.getByText('角色')).toBeTruthy(); + fireEvent.click( + screen.getAllByRole('button', { + name: /查看角色形象 .*图片信息/u, + })[0]!, + ); + const characterInfoPanel = screen.getByRole('dialog', { + name: /角色形象 .*图片信息/u, + }); + expect(within(characterInfoPanel).queryByText('Prompt')).toBeNull(); + expect(within(characterInfoPanel).getByText('生成输入')).toBeTruthy(); + expect(within(characterInfoPanel).getByText('角色设定')).toBeTruthy(); + expect( + within(characterInfoPanel).getByText( + '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', + ), + ).toBeTruthy(); + expect(within(characterInfoPanel).getByText('角色形象规范')).toBeTruthy(); + expect(within(characterInfoPanel).getByText('拼图素材')).toBeTruthy(); + expect(within(characterInfoPanel).getByText('常规参考图 1')).toBeTruthy(); + expect(within(characterInfoPanel).getByText('常规参考.png')).toBeTruthy(); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + title: expect.stringMatching(/角色形象/u), + assetKind: 'character', + objectKey: + 'generated-character-drafts/editor/character-images/editor-character-1/image.png', + assetObjectId: 'asset-object-editor-character-1', + }), + ]), + }), + ); + }); + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + objectKey: + 'generated-character-drafts/editor/character-images/editor-character-1/image.png', + assetObjectId: 'asset-object-editor-character-1', + }), + ); + }); + }); + + it('removes the active character generation placeholder with Backspace', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + + expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); + expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); + + await act(async () => { + fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); + }); + + expect(screen.queryByLabelText('角色生成占位图')).toBeNull(); + expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); + expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); + }); + + it('opens icon asset generation panel, only picks icon specs, and lays generated icons on canvas', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-icons', + title: '图标素材画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-plain', + resourceId: 'resource-plain', + title: '普通参考图', + src: 'data:image/png;base64,plain', + x: 80, + y: 80, + width: 120, + height: 120, + originalWidth: 512, + originalHeight: 512, + zIndex: 10, + sourceType: 'uploaded', + }, + { + layerId: 'layer-icon-spec', + resourceId: 'resource-icon-spec', + title: '清爽按钮图标规范', + src: 'data:image/png;base64,icon-spec', + x: 240, + y: 80, + width: 160, + height: 120, + originalWidth: 2048, + originalHeight: 1152, + zIndex: 11, + sourceType: 'generated', + assetKind: 'icon-spec', + }, + ], + resources: [], + updatedAt: '2026-06-15T00:00:00.000Z', + }); + generateEditorIconSpritesheetMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,sheet', + spritesheetWidth: 512, + spritesheetHeight: 512, + iconImageSrcs: [ + { + name: '返回按钮', + imageSrc: 'data:image/png;base64,back-icon', + width: 96, + height: 96, + }, + { + name: '设置按钮', + imageSrc: 'data:image/png;base64,setting-icon', + width: 96, + height: 96, + }, + ], + prompt: '图标 prompt', + actualPrompt: '图标 prompt', + model: 'gemini-3.1-flash-image-preview', + provider: 'VectorEngine', + taskId: 'icon-task-1', + }); + render(); + + await waitFor(() => { + expect(screen.getByAltText('画布图片:普通参考图')).toBeTruthy(); + expect(screen.getByAltText('画布图片:清爽按钮图标规范')).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect(screen.getByLabelText('图标素材生成占位图')).toBeTruthy(); + expect( + within(iconPanel).getByRole('button', { name: '图标素材规范' }), + ).toBeTruthy(); + expect( + (within(iconPanel).getAllByRole('textbox')[0] as HTMLInputElement).value, + ).toBe('返回按钮'); + expect( + (within(iconPanel).getAllByRole('textbox')[5] as HTMLInputElement).value, + ).toBe('冻结按钮'); + + fireEvent.click( + within(iconPanel).getByRole('button', { name: '图标素材规范' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); + expect( + screen.getByText('请选择画布中的图标素材规范,按 Esc 退出'), + ).toBeTruthy(); + + fireEvent.pointerDown( + screen.getByAltText('画布图片:普通参考图').closest('button')!, + { + button: 0, + pointerId: 180, + clientX: 100, + clientY: 100, + }, + ); + expect( + within(iconPanel).getByRole('button', { name: '图标素材规范' }), + ).toBeTruthy(); + + fireEvent.pointerDown( + screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, + { + button: 0, + pointerId: 181, + clientX: 260, + clientY: 100, + }, + ); + expect( + within(iconPanel).getByRole('button', { name: '清爽按钮图标规范' }), + ).toBeTruthy(); + expect( + screen.queryByText('请选择画布中的图标素材规范,按 Esc 退出'), + ).toBeNull(); + + const iconDescriptionInputs = within(iconPanel).getAllByRole('textbox'); + const [ + , + , + iconDescription3, + iconDescription4, + iconDescription5, + iconDescription6, + ] = iconDescriptionInputs; + expect(iconDescription3).toBeTruthy(); + expect(iconDescription4).toBeTruthy(); + expect(iconDescription5).toBeTruthy(); + expect(iconDescription6).toBeTruthy(); + + fireEvent.change(iconDescription3!, { + target: { value: '' }, + }); + fireEvent.change(iconDescription4!, { + target: { value: '' }, + }); + fireEvent.change(iconDescription5!, { + target: { value: '' }, + }); + fireEvent.change(iconDescription6!, { + target: { value: '' }, + }); + fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); + + expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith({ + referenceImageSrc: 'data:image/png;base64,icon-spec', + iconDescriptions: ['返回按钮', '设置按钮'], + }); + + await waitFor(() => { + expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy(); + expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy(); + }); + expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull(); + expect(screen.getAllByText('图标')).toHaveLength(2); + fireEvent.click( + screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!, + ); + const iconInfoPanel = screen.getByRole('dialog', { + name: '返回按钮图片信息', + }); + expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull(); + expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy(); + expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy(); + expect(within(iconInfoPanel).getByText('素材描述 2')).toBeTruthy(); + expect(within(iconInfoPanel).getByText('返回按钮')).toBeTruthy(); + expect(within(iconInfoPanel).getByText('设置按钮')).toBeTruthy(); + expect(within(iconInfoPanel).getByText('图标素材规范')).toBeTruthy(); + expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy(); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-icons', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + title: '返回按钮', + assetKind: 'icon', + }), + expect.objectContaining({ + title: '设置按钮', + assetKind: 'icon', + }), + ]), + }), + ); + }); + }); + + it('exits character generation canvas picking with Escape', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); + fireEvent.click( + within(characterPanel).getByRole('button', { name: '角色形象规范' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); + + expect( + screen.getByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), + ).toBeTruthy(); + + fireEvent.keyDown(window, { key: 'Escape' }); + + expect( + screen.queryByText('请选择画布中的图片作为角色形象规范,按 Esc 退出'), + ).toBeNull(); + expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); + }); + + it('only exposes character animation generation for character layers and submits the panel payload', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-character-animation', + title: '角色动画画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-character', + resourceId: 'resource-character', + title: '市场老妇人', + src: 'data:image/png;base64,character', + x: 160, + y: 140, + width: 320, + height: 320, + originalWidth: 1024, + originalHeight: 1024, + zIndex: 2, + sourceType: 'generated', + objectKey: + 'generated-character-drafts/editor/character-images/source/image.png', + assetKind: 'character', + }, + { + layerId: 'layer-prop', + resourceId: 'resource-prop', + title: '普通道具', + src: 'data:image/png;base64,prop', + x: 520, + y: 140, + width: 280, + height: 220, + originalWidth: 700, + originalHeight: 550, + zIndex: 1, + sourceType: 'uploaded', + }, + ], + resources: [], + updatedAt: '2026-06-15T00:00:00.000Z', + }); + generateEditorCharacterAnimationMock.mockResolvedValueOnce({ + taskId: 'character-animation-task-1', + model: 'seedance2.0', + prompt: '生成游戏角色动画\n动作描述:\n待机', + previewVideoPath: '/generated-character-drafts/editor/preview.mp4', + frames: Array.from({ length: 48 }, (_, index) => ({ + frameIndex: index + 1, + imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`, + width: 1024, + height: 1024, + })), + frameCount: 48, + durationSeconds: 6, + fps: 8, + priceMudPoints: 120, + }); + render(); + + const propLayer = await screen.findByAltText('画布图片:普通道具'); + fireEvent.click(propLayer.closest('button')!); + expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull(); + fireEvent.contextMenu(propLayer.closest('button')!, { + clientX: 220, + clientY: 180, + }); + expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull(); + + const characterLayer = screen.getByAltText('画布图片:市场老妇人'); + fireEvent.click(characterLayer.closest('button')!); + expect(screen.getByText('角色')).toBeTruthy(); + expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy(); + fireEvent.contextMenu(characterLayer.closest('button')!, { + clientX: 260, + clientY: 220, + }); + expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '生成动画' })); + const panel = screen.getByRole('dialog', { name: '角色动画生成面板' }); + expect(within(panel).getByText('40泥点')).toBeTruthy(); + expect( + (within(panel).getByLabelText('分辨率') as HTMLSelectElement).value, + ).toBe('480p'); + expect( + (within(panel).getByLabelText('画面比例') as HTMLSelectElement).value, + ).toBe('same'); + expect( + (within(panel).getByLabelText('时长') as HTMLSelectElement).value, + ).toBe('32'); + for (const actionLabel of [ + '待机', + '行走', + '奔跑', + '跳跃', + '攻击', + '受击', + '倒下', + ]) { + expect( + within(panel).getByRole('button', { name: actionLabel }), + ).toBeTruthy(); + } + fireEvent.click(within(panel).getByRole('button', { name: '待机' })); + expect( + (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value, + ).toContain('待机'); + const longPrompt = '走'.repeat(4100); + fireEvent.change(within(panel).getByLabelText('动画描述'), { + target: { value: longPrompt }, + }); + expect( + (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value, + ).toHaveLength(4000); + const precisePrompt = + 'The elderly market woman gently shifts weight while the basket sways.'; + fireEvent.change(within(panel).getByLabelText('动画描述'), { + target: { value: precisePrompt }, + }); + expect( + within(panel).getByLabelText(`生成文本:${precisePrompt}`), + ).toBeTruthy(); + fireEvent.change(within(panel).getByLabelText('分辨率'), { + target: { value: '720p' }, + }); + fireEvent.change(within(panel).getByLabelText('画面比例'), { + target: { value: '16:9' }, + }); + fireEvent.change(within(panel).getByLabelText('时长'), { + target: { value: '48' }, + }); + expect(within(panel).getByText('120泥点')).toBeTruthy(); + fireEvent.click(within(panel).getByRole('button', { name: '生成' })); + + expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceLayerId: 'layer-character', + sourceImageSrc: + 'generated-character-drafts/editor/character-images/source/image.png', + sourceWidth: 1024, + sourceHeight: 1024, + resolution: '720p', + ratio: '16:9', + frameCount: 48, + durationSeconds: 6, + priceMudPoints: 120, + model: 'seedance2.0', + }), + ); + expect( + generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText, + ).toBe(precisePrompt); + await waitFor(() => { + expect(within(panel).getByText('已生成 48 帧')).toBeTruthy(); + }); + }); + + it('opens quick edit from the floating toolbar with original image as first reference and generates beside the source', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-quick-edit', + title: '快速编辑画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-quick-source', + resourceId: 'resource-quick-source', + title: '魔法森林', + src: 'data:image/png;base64,c291cmNl', + x: 120, + y: 140, + width: 320, + height: 240, + originalWidth: 1536, + originalHeight: 1024, + zIndex: 2, + sourceType: 'generated', + prompt: '魔法森林原始提示词', + actualPrompt: '魔法森林原始提示词', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'source-task-1', + assetKind: 'spec', + }, + ], + resources: [], + updatedAt: '2026-06-15T00:00:00.000Z', + }); + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==', + width: 1536, + height: 1024, + sourceType: 'generated', + prompt: '增加萤火虫', + actualPrompt: '增加萤火虫', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'quick-edit-task-1', + }); + render(); + + const sourceImage = await screen.findByAltText('画布图片:魔法森林'); + fireEvent.pointerDown(sourceImage.closest('button')!, { + button: 0, + pointerId: 151, + clientX: 180, + clientY: 180, + }); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 151, + clientX: 180, + clientY: 180, + }); + fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); + + const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' }); + expect(quickPanel.className).toContain( + 'image-canvas-editor__quick-edit-panel', + ); + expect(within(quickPanel).getByText('魔法森林')).toBeTruthy(); + expect( + (within(quickPanel).getByLabelText('快速编辑尺寸') as HTMLSelectElement) + .value, + ).toBe('1536x1024'); + expect( + (within(quickPanel).getByLabelText('快速编辑模型') as HTMLSelectElement) + .value, + ).toBe('gpt-image-2'); + const references = within(quickPanel).getAllByRole('img'); + expect(references[0]?.getAttribute('src')).toBe( + 'data:image/png;base64,c291cmNl', + ); + + fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), { + target: { value: '增加萤火虫' }, + }); + fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith({ + prompt: '增加萤火虫', + size: '1536x1024', + kind: 'quick-edit', + model: 'gpt-image-2', + referenceImageSrcs: ['data:image/png;base64,c291cmNl'], + }); + }); + await waitFor(() => { + expect(screen.getByAltText('画布图片:魔法森林 快速编辑')).toBeTruthy(); + }); + const generatedLayer = screen + .getByAltText('画布图片:魔法森林 快速编辑') + .closest('button') as HTMLElement; + expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688); + expect(Number.parseFloat(generatedLayer.style.top)).toBe(140); + expect(Number.parseFloat(generatedLayer.style.width)).toBe(1536); + expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-quick-edit', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + title: '魔法森林 快速编辑', + assetKind: 'spec', + width: 1536, + height: 1024, + originalWidth: 1536, + originalHeight: 1024, + x: 1688, + y: 140, + }), + ]), + }), + ); + }); + }); + + it('opens quick edit from the image context menu', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-context-quick-edit', + title: '右键快速编辑画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-context-source', + resourceId: 'resource-context-source', + title: '右键图片', + src: 'data:image/png;base64,Y29udGV4dA==', + x: 80, + y: 90, + width: 260, + height: 260, + originalWidth: 1024, + originalHeight: 1024, + zIndex: 1, + sourceType: 'uploaded', + model: 'gpt-image-2', + }, + ], + resources: [], + updatedAt: '2026-06-15T00:00:00.000Z', + }); + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==', + width: 1024, + height: 1024, + sourceType: 'generated', + prompt: '换成夜晚', + actualPrompt: '换成夜晚', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'context-quick-task-1', + }); + render(); + + const contextImage = await screen.findByAltText('画布图片:右键图片'); + fireEvent.contextMenu(contextImage.closest('button')!, { + clientX: 260, + clientY: 220, + }); + + const menu = screen.getByRole('menu', { name: '图片功能面板' }); + expect( + within(menu).getByRole('menuitem', { name: '快速编辑' }), + ).toBeTruthy(); + fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' })); + + const panel = screen.getByRole('dialog', { name: '快速编辑图片' }); + expect(within(panel).getByText('右键图片')).toBeTruthy(); + fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), { + target: { value: '换成夜晚' }, + }); + fireEvent.click(within(panel).getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '换成夜晚', + referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='], + size: '1024x1024', + model: 'gpt-image-2', + kind: 'quick-edit', + }), + ); + }); + await waitFor(() => { + expect(screen.getByAltText('画布图片:右键图片 快速编辑')).toBeTruthy(); + }); + }); + + it('converts non-data-url quick edit source images before submitting references', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-public-quick-edit', + title: '公开素材快速编辑画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-public-source', + resourceId: 'resource-public-source', + title: '公开拼图素材', + src: '/creation-type-references/puzzle.webp', + x: 120, + y: 140, + width: 320, + height: 240, + originalWidth: 640, + originalHeight: 640, + zIndex: 2, + sourceType: 'uploaded', + }, + ], + resources: [], + updatedAt: '2026-06-16T00:00:00.000Z', + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(new Uint8Array([104, 101, 108, 108, 111]), { + status: 200, + headers: { + 'Content-Type': 'image/webp', + }, + }), + ); + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr', + width: 640, + height: 640, + sourceType: 'generated', + prompt: '改成陶泥风格', + actualPrompt: '改成陶泥风格', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'public-quick-edit-task-1', + }); + render(); + + const sourceImage = await screen.findByAltText('画布图片:公开拼图素材'); + fireEvent.pointerDown(sourceImage.closest('button')!, { + button: 0, + pointerId: 161, + clientX: 180, + clientY: 180, + }); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 161, + clientX: 180, + clientY: 180, + }); + fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); + + const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' }); + fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), { + target: { value: '改成陶泥风格' }, + }); + fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '改成陶泥风格', + kind: 'quick-edit', + referenceImageSrcs: ['data:image/webp;base64,aGVsbG8='], + }), + ); + }); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/creation-type-references/puzzle.webp', + expect.objectContaining({ + signal: undefined, + }), + ); + }); + + it('switches tools and restores the previous tool after holding Space', async () => { + const user = userEvent.setup(); + render(); + + const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); + const selectTool = within(bottomToolbar).getByRole('button', { + name: '选择工具', + }); + const textTool = within(bottomToolbar).getByRole('button', { + name: '文字工具', + }); + const handTool = within(bottomToolbar).getByRole('button', { + name: '抓手工具', + }); expect(selectTool.getAttribute('aria-pressed')).toBe('true'); @@ -2599,11 +4120,15 @@ describe('ImageCanvasEditorView', () => { it('switches away from hand tool from the bottom toolbar', async () => { const user = userEvent.setup(); - await renderLoadedEditor(); + render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); - const handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具' }); - const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' }); + const handTool = within(bottomToolbar).getByRole('button', { + name: '抓手工具', + }); + const textTool = within(bottomToolbar).getByRole('button', { + name: '文字工具', + }); await user.click(handTool); expect(handTool.getAttribute('aria-pressed')).toBe('true'); @@ -2614,7 +4139,7 @@ describe('ImageCanvasEditorView', () => { }); it('pans with the middle mouse button without leaving select mode', async () => { - await renderLoadedEditor(); + render(); const viewport = screen.getByLabelText('画布工作区'); const middlePointerDown = new MouseEvent('pointerdown', { @@ -2629,7 +4154,9 @@ describe('ImageCanvasEditorView', () => { fireEvent(viewport, middlePointerDown); await waitFor(() => { - expect(viewport.className).toContain('image-canvas-editor__viewport--panning'); + expect(viewport.className).toContain( + 'image-canvas-editor__viewport--panning', + ); }); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); expect( @@ -2640,9 +4167,11 @@ describe('ImageCanvasEditorView', () => { }); it('shows snap guides when dragging a layer near another layer alignment', async () => { - await renderLoadedEditor(); + render(); - const puzzleLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!; + const puzzleLayer = screen + .getByAltText('画布图片:拼图素材') + .closest('button')!; dispatchPointerEvent(puzzleLayer, 'pointerdown', { button: 0, pointerId: 21, @@ -2651,24 +4180,31 @@ describe('ImageCanvasEditorView', () => { }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 21, - clientX: 580, + clientX: -60, clientY: 180, }); - expect(screen.getByTestId('image-canvas-editor-snap-guide-vertical')).toBeTruthy(); - expect(screen.getByTestId('image-canvas-editor-snap-guide-horizontal')).toBeTruthy(); + expect( + screen.getByTestId('image-canvas-editor-snap-guide-vertical'), + ).toBeTruthy(); + expect( + screen.getByTestId('image-canvas-editor-snap-guide-horizontal'), + ).toBeTruthy(); }); it('can switch tools after a layer drag started without pointer release', async () => { const user = userEvent.setup(); - await renderLoadedEditor(); + render(); - fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { - button: 0, - pointerId: 41, - clientX: 120, - clientY: 120, - }); + fireEvent.pointerDown( + screen.getByAltText('画布图片:拼图素材').closest('button')!, + { + button: 0, + pointerId: 41, + clientX: 120, + clientY: 120, + }, + ); fireEvent.pointerMove(screen.getByLabelText('画布工作区'), { pointerId: 41, clientX: 220, @@ -2676,14 +4212,18 @@ describe('ImageCanvasEditorView', () => { }); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); - const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' }); + const textTool = within(bottomToolbar).getByRole('button', { + name: '文字工具', + }); await user.click(textTool); expect(textTool.getAttribute('aria-pressed')).toBe('true'); - expect(screen.queryByTestId('image-canvas-editor-snap-guide-vertical')).toBeNull(); + expect( + screen.queryByTestId('image-canvas-editor-snap-guide-vertical'), + ).toBeNull(); }); - it('opens generated image metadata from the corner button and creates a real right-side edit result', async () => { + it('opens generated image info from the corner button and creates a real right-side edit result', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', width: 1024, @@ -2706,7 +4246,7 @@ describe('ImageCanvasEditorView', () => { provider: 'VectorEngine', taskId: 'editor-real-edit-1', }); - await renderLoadedEditor(); + render(); fireEvent.click(screen.getByRole('button', { name: '生成工具' })); fireEvent.change(screen.getByLabelText('生成提示词'), { @@ -2717,10 +4257,15 @@ describe('ImageCanvasEditorView', () => { await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); + const generatedLayer = screen + .getByAltText(/画布图片:生成图片/) + .closest('button') as HTMLElement; + expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024); + expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); const metadataCornerButton = screen.getAllByRole('button', { - name: /查看生成图片 .*元数据/, + name: /查看生成图片 .*图片信息/, })[0]; if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); @@ -2731,47 +4276,190 @@ describe('ImageCanvasEditorView', () => { ); fireEvent.click(metadataCornerButton); - const metadataDialog = screen.getByRole('dialog', { name: /生成图片 .*元数据/ }); + const metadataDialog = screen.getByRole('dialog', { + name: /生成图片 .*图片信息/, + }); expect(metadataDialog).toBeTruthy(); + expect(within(metadataDialog).getByText('图片类型')).toBeTruthy(); + expect(within(metadataDialog).getByText('生成图片')).toBeTruthy(); + expect(within(metadataDialog).queryByText('Prompt')).toBeNull(); + expect( + within(metadataDialog).queryByRole('button', { name: '复制Prompt' }), + ).toBeNull(); + expect(within(metadataDialog).getByText('生成输入')).toBeTruthy(); + expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy(); + expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy(); + expect(within(metadataDialog).getByText('Model')).toBeTruthy(); expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy(); + expect(within(metadataDialog).queryByText('Size')).toBeNull(); + expect(within(metadataDialog).getByText('Resolution')).toBeTruthy(); + expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '修改图片' })); const editDialog = screen.getByRole('dialog', { name: '修改图片' }); expect(editDialog).toBeTruthy(); const editPrompt = screen.getByLabelText('生成提示词'); expect(editPrompt.className).toContain('platform-text-field'); - expect(editPrompt.className).toContain('image-canvas-editor__generate-prompt'); + expect(editPrompt.className).toContain( + 'image-canvas-editor__generate-prompt', + ); fireEvent.change(editPrompt, { target: { value: '把画面改成黄昏光线' }, }); fireEvent.click(screen.getByRole('button', { name: '修改' })); expect(screen.getByRole('status').textContent).toContain('修改中'); - expect(editEditorImageMock).toHaveBeenCalledWith({ - prompt: '把画面改成黄昏光线', - sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', + await waitFor(() => { + expect(editEditorImageMock).toHaveBeenCalledWith({ + prompt: '把画面改成黄昏光线', + sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', + }); }); await waitFor(() => { expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); }); expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy(); - await waitFor(() => { - expect(createEditorAssetMock).toHaveBeenCalledWith( - expect.objectContaining({ - label: expect.stringMatching(/修改结果/u), - imageSrc: 'data:image/png;base64,ZWRpdGVkLWltYWdl', - sourceType: 'generated', - prompt: '把画面改成黄昏光线', - taskId: 'editor-real-edit-1', - }), - ); + fireEvent.click( + screen.getAllByRole('button', { + name: /查看生成图片 .* 修改结果图片信息/u, + })[0]!, + ); + const editedMetadataDialog = screen.getByRole('dialog', { + name: /生成图片 .* 修改结果图片信息/u, }); - expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy(); + expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); + expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy(); + expect(within(editedMetadataDialog).getByText('把画面改成黄昏光线')).toBeTruthy(); + expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy(); + expect( + within(editedMetadataDialog).getByText(/^生成图片 \d+$/u), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy(); }); - it('undoes and redoes canvas layer changes from the panel controls', async () => { - await renderLoadedEditor(); + it('hides the edit image panel after generation starts while keeping the source preview visible', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-edit-generating', + title: '修改图片生成中画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-edit-generating-source', + resourceId: 'resource-edit-generating-source', + title: '待修改图片', + src: 'data:image/png;base64,ZWRpdC1nZW5lcmF0aW5n', + x: 120, + y: 140, + width: 320, + height: 240, + originalWidth: 1024, + originalHeight: 768, + zIndex: 2, + sourceType: 'generated', + prompt: '原始提示词', + actualPrompt: '原始提示词', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'edit-generating-source-task', + }, + ], + resources: [], + updatedAt: '2026-06-16T00:00:00.000Z', + }); + editEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); + render(); + + const sourceImage = await screen.findByAltText('画布图片:待修改图片'); + const sourceLayer = sourceImage.closest('button')!; + fireEvent.pointerDown(sourceLayer, { + button: 0, + pointerId: 171, + clientX: 180, + clientY: 180, + }); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 171, + clientX: 180, + clientY: 180, + }); + fireEvent.click(screen.getByRole('button', { name: '修改图片' })); + const editDialog = screen.getByRole('dialog', { name: '修改图片' }); + fireEvent.change(within(editDialog).getByLabelText('生成提示词'), { + target: { value: '改成雨夜灯光' }, + }); + fireEvent.click(within(editDialog).getByRole('button', { name: '修改' })); + + expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); + expect(screen.getByAltText('画布图片:待修改图片')).toBeTruthy(); + expect(sourceLayer.className).toContain( + 'image-canvas-editor__layer--generating', + ); + expect(within(sourceLayer).getByRole('status').textContent).toContain( + '修改中', + ); + }); + + it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-quick-edit-generating', + title: '快速编辑生成中画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-quick-edit-generating-source', + resourceId: 'resource-quick-edit-generating-source', + title: '快速编辑源图', + src: 'data:image/png;base64,cXVpY2stZWRpdC1nZW5lcmF0aW5n', + x: 120, + y: 140, + width: 320, + height: 240, + originalWidth: 1024, + originalHeight: 768, + zIndex: 2, + sourceType: 'uploaded', + model: 'gpt-image-2', + }, + ], + resources: [], + updatedAt: '2026-06-16T00:00:00.000Z', + }); + generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); + render(); + + const sourceImage = await screen.findByAltText('画布图片:快速编辑源图'); + const sourceLayer = sourceImage.closest('button')!; + fireEvent.pointerDown(sourceLayer, { + button: 0, + pointerId: 172, + clientX: 180, + clientY: 180, + }); + fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { + pointerId: 172, + clientX: 180, + clientY: 180, + }); + fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); + const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' }); + fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), { + target: { value: '加一层暖光' }, + }); + fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' })); + + expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(); + expect(screen.getByAltText('画布图片:快速编辑源图')).toBeTruthy(); + expect(sourceLayer.className).toContain( + 'image-canvas-editor__layer--generating', + ); + expect(within(sourceLayer).getByRole('status').textContent).toContain( + '生成中', + ); + }); + + it('undoes and redoes canvas layer changes from the panel controls', () => { + render(); expect(screen.getByRole('button', { name: '撤销' })).toHaveProperty( 'disabled', @@ -2801,8 +4489,8 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); }); - it('supports undo and redo keyboard shortcuts inside the editor', async () => { - await renderLoadedEditor(); + it('supports undo and redo keyboard shortcuts inside the editor', () => { + render(); fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' })); expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f058e85f..84ebe779 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -5,6 +5,7 @@ import { ChevronDown, ChevronLeft, ChevronRight, + ClipboardList, Copy, Crop, Download, @@ -19,6 +20,7 @@ import { MousePointer2, Pencil, PencilLine, + Redo2, RotateCcw, Shapes, SlidersHorizontal, @@ -26,17 +28,16 @@ import { Square, Trash2, Type, - Redo2, Undo2, WandSparkles, X, } from 'lucide-react'; import JSZip from 'jszip'; import { + type CSSProperties, type DragEvent as ReactDragEvent, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, + type ReactNode, useCallback, useEffect, useMemo, @@ -44,17 +45,28 @@ import { useState, type WheelEvent as ReactWheelEvent, } from 'react'; +import { createPortal } from 'react-dom'; +import { ApiClientError } from '../../services/apiClient'; +import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; import { - createEditorProjectResource, createEditorAsset, createEditorAssetFolder, + createEditorProjectResource, deleteEditorAsset, deleteEditorAssetFolder, editEditorImage, type EditorAssetLibrarySnapshot, + type EditorCharacterAnimationFrameCount, + type EditorCharacterAnimationGenerationResult, + type EditorCharacterAnimationRatio, + type EditorCharacterAnimationResolution, + type EditorIconSpritesheetGenerationResult, + type EditorIconSpritesheetIconResult, type EditorImageGenerationResult, type EditorProjectLayerSnapshot, + generateEditorCharacterAnimation, + generateEditorIconSpritesheet, generateEditorImage, loadEditorAssetLibrary, loadEditorProject, @@ -64,13 +76,9 @@ import { updateEditorAsset, updateEditorAssetFolder, } from '../../services/image-editor/editorProjectClient'; -import { ApiClientError } from '../../services/apiClient'; -import { - EditorIconButton, - SidebarMediaItem, -} from './ImageCanvasEditorPrimitives'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar'; +import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { PlatformFloatingMenu, PlatformFloatingMenuItem, @@ -79,9 +87,16 @@ import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; -import { PlatformTextField } from '../common/PlatformTextField'; +import { + PlatformSelectField, + PlatformTextField, +} from '../common/PlatformTextField'; import { UnifiedModal } from '../common/UnifiedModal'; import { useAuthUi } from '../auth/AuthUiContext'; +import { + EditorIconButton, + SidebarMediaItem, +} from './ImageCanvasEditorPrimitives'; type EditorAsset = { id: string; @@ -105,6 +120,22 @@ type EditorAsset = { uploadMessage?: string; }; +type CanvasGenerationInputField = { + title: string; + value: string; +}; + +type CanvasGenerationInputReference = { + title: string; + label: string; + src: string; +}; + +type CanvasGenerationInputs = { + fields: CanvasGenerationInputField[]; + references: CanvasGenerationInputReference[]; +}; + type CanvasLayer = { id: string; resourceId: string; @@ -128,6 +159,8 @@ type CanvasLayer = { sourceResourceId?: string | null; sourceAssetId?: string | null; groupId?: string | null; + assetKind?: 'spec' | 'character' | 'icon' | 'icon-spec' | null; + generationInputs?: CanvasGenerationInputs | null; hidden?: boolean; locked?: boolean; flipX?: boolean; @@ -145,6 +178,9 @@ type CanvasTool = | 'hand' | 'upload' | 'generate' + | 'spec' + | 'character' + | 'icon' | 'text' | 'shape' | 'export'; @@ -160,12 +196,19 @@ type EditorAssetFolder = { }; type GenerateDialogState = { - mode: 'generate' | 'edit'; + id?: string; + mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon'; prompt: string; status: 'idle' | 'generating' | 'failed'; composerOpen?: boolean; sourceLayerId?: string; generatedLayerId?: string; + specType?: SpecGenerationType; + specValues?: SpecFormValues; + characterSpecReference?: CharacterReferenceImage | null; + characterReferences?: CharacterReferenceImage[]; + iconSpecReference?: CharacterReferenceImage | null; + iconDescriptions?: string[]; errorMessage?: string; placeholder?: { x: number; @@ -177,6 +220,141 @@ type GenerateDialogState = { }; }; +type CanvasGenerationDialogMode = Exclude; + +type CanvasGenerationDialogState = GenerateDialogState & { + id: string; + mode: CanvasGenerationDialogMode; +}; + +type SpecGenerationType = 'character' | 'ui' | 'icon' | 'custom'; + +type SpecFormValues = { + playSetting: string; + artStyle: string; + bodyRatio: string; + characterView: string; + customPrompt: string; +}; + +type CharacterReferenceImage = { + id: string; + label: string; + src: string; +}; + +type ImageContextMenuState = { + layerId: string; + x: number; + y: number; +}; + +type CanvasHistorySnapshot = { + layers: CanvasLayer[]; + viewport: CanvasViewport; + generateDialog: GenerateDialogState | null; + inactiveGenerateDialogs: CanvasGenerationDialogState[]; + selectedLayerId: string | null; + selectedLayerIds: string[]; +}; + +type CanvasClipboard = { + layers: CanvasLayer[]; + mode: 'copy' | 'cut'; +}; + +type CanvasContextMenuState = + | { + kind: 'blank'; + x: number; + y: number; + canvasPoint: { x: number; y: number }; + } + | { + kind: 'layer'; + x: number; + y: number; + layerId: string; + canvasPoint: { x: number; y: number }; + }; + +type CanvasAssetExportImage = { + key: string; + file: string; + layer: CanvasLayer; + blob?: Blob; + error?: string; +}; + +type CanvasAssetExportMetadata = { + projectId: string | null; + projectTitle: string; + exportedAt: string; + layers: Array<{ + layerId: string; + title: string; + file: string | null; + sourceType: CanvasLayer['sourceType']; + prompt?: string | null; + actualPrompt?: string | null; + model?: string | null; + provider?: string | null; + taskId?: string | null; + objectKey?: string | null; + assetObjectId?: string | null; + sourceResourceId?: string | null; + sourceAssetId?: string | null; + exportError?: string; + canvas: { + x: number; + y: number; + width: number; + height: number; + originalWidth: number; + originalHeight: number; + zIndex: number; + groupId?: string | null; + hidden?: boolean; + locked?: boolean; + flipX?: boolean; + flipY?: boolean; + }; + }>; + failedImages: Array<{ + key: string; + title: string; + src: string; + error: string; + }>; +}; + +type QuickEditPanelState = { + sourceLayerId: string; + prompt: string; + size: string; + model: string; + status: 'idle' | 'generating' | 'failed'; + errorMessage?: string; +}; + +type CharacterAnimationPanelState = { + sourceLayerId: string; + promptText: string; + resolution: EditorCharacterAnimationResolution; + ratio: EditorCharacterAnimationRatio; + frameCount: EditorCharacterAnimationFrameCount; + durationSeconds: 4 | 5 | 6; + status: 'idle' | 'generating' | 'completed' | 'failed'; + errorMessage?: string; + result?: EditorCharacterAnimationGenerationResult; +}; + +type UploadTarget = + | 'asset' + | 'character-spec' + | 'character-reference' + | 'icon-spec'; + type SnapGuide = { vertical?: number; horizontal?: number; @@ -215,83 +393,6 @@ type CanvasMarqueeState = { currentY: number; }; -type CanvasHistorySnapshot = { - layers: CanvasLayer[]; - viewport: CanvasViewport; - generateDialog: GenerateDialogState | null; - selectedLayerId: string | null; - selectedLayerIds: string[]; -}; - -type CanvasClipboard = { - layers: CanvasLayer[]; -}; - -type CanvasContextMenuState = - | { - kind: 'blank'; - x: number; - y: number; - canvasPoint: { x: number; y: number }; - } - | { - kind: 'layer'; - x: number; - y: number; - layerId: string; - canvasPoint: { x: number; y: number }; - }; - -type UploadDropTarget = 'canvas' | 'assets' | null; - -type CanvasAssetExportImage = { - key: string; - file: string; - layer: CanvasLayer; - blob?: Blob; - error?: string; -}; - -type CanvasAssetExportLayerMetadata = { - layerId: string; - title: string; - file: string | null; - width: number; - height: number; - canvas: { - x: number; - y: number; - width: number; - height: number; - zIndex: number; - hidden: boolean; - locked: boolean; - flipX: boolean; - flipY: boolean; - groupId: string | null; - }; - sourceType: CanvasLayer['sourceType']; - prompt: string | null; - actualPrompt: string | null; - model: string | null; - provider: string | null; - taskId: string | null; - exportError?: string; -}; - -type CanvasAssetExportMetadata = { - projectId: string | null; - projectTitle: string; - exportedAt: string; - layers: CanvasAssetExportLayerMetadata[]; - failedImages: Array<{ - key: string; - title: string; - src: string; - error: string; - }>; -}; - type DragState = | { kind: 'pan'; @@ -314,6 +415,7 @@ type DragState = } | { kind: 'generation-frame'; + dialogId: string; pointerId: number; startClientX: number; startClientY: number; @@ -331,6 +433,8 @@ type DragState = moved: boolean; }; +const EDITOR_ASSETS: EditorAsset[] = []; + const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { id: 'project', @@ -341,11 +445,12 @@ const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ }, ]; +const INITIAL_LAYERS: CanvasLayer[] = []; + const CANVAS_WORLD_SIZE = 12000; const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; const MIN_SCALE = 0.24; const MAX_SCALE = 3.2; -const MAX_HISTORY_STEPS = 80; const TOOLBAR_HALF_WIDTH = 132; const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; const SNAP_THRESHOLD_SCREEN_PX = 18; @@ -353,20 +458,142 @@ const FIT_VIEW_PADDING = 10; const MINIMAP_SIZE = { width: 132, height: 84 }; const MINIMAP_PADDING = 8; const MINIMAP_DRAG_SENSITIVITY = 0.3; -const CONTEXT_MENU_SIZE = { - blank: { width: 176, height: 148 }, - layer: { width: 176, height: 444 }, -}; -const CONTEXT_MENU_VIEWPORT_MARGIN = 8; const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset'; -const DEFAULT_CANVAS_BACKGROUND_COLOR = '#f8fafc'; +const MAX_HISTORY_STEPS = 60; +const CONTEXT_MENU_VIEWPORT_MARGIN = 8; +const CONTEXT_MENU_SIZE = { + blank: { width: 188, height: 176 }, + layer: { width: 188, height: 492 }, +} as const; +const SPEC_GENERATION_COST = 5; +const SPEC_GENERATION_SIZE = '2048x1152'; +const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; +const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; +const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 }; +const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 }; +const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 }; +const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 }; +const DEFAULT_IMAGE_MODEL = 'gpt-image-2'; +const ICON_DESCRIPTION_LIMIT = 100; +// 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。 +const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4; +const ICON_COMPOSER_MIN_WIDTH_REM = 28; +const ICON_COMPOSER_HORIZONTAL_CHROME_REM = 2.4; +const DEFAULT_ICON_DESCRIPTIONS = [ + '返回按钮', + '设置按钮', + '下一关按钮', + '提示按钮', + '原图按钮', + '冻结按钮', +]; +const QUICK_EDIT_SIZE_PRESETS = [ + '1024x1024', + '1536x1024', + '2048x1152', + '1024x1536', +] as const; +const QUICK_EDIT_MODEL_OPTIONS = [ + { label: 'GPT Image', value: DEFAULT_IMAGE_MODEL }, +] as const; +const CHARACTER_ANIMATION_MODEL = 'seedance2.0'; +const CHARACTER_ANIMATION_ACTION_PROMPTS = [ + { label: '待机', text: '待机动作,轻微呼吸起伏。' }, + { label: '行走', text: '循环行走动作,步伐稳定。' }, + { label: '奔跑', text: '循环奔跑动作,动作清晰有力。' }, + { label: '跳跃', text: '起跳、滞空、落地动作。' }, + { label: '攻击', text: '攻击动作,前摇、出手、收招清晰。' }, + { label: '受击', text: '受击后短暂后仰并恢复站姿。' }, + { label: '倒下', text: '倒下动作,重心下落自然。' }, +] as const; +const CHARACTER_ANIMATION_RATIO_OPTIONS: Array<{ + label: string; + value: EditorCharacterAnimationRatio; +}> = [ + { label: '与角色图片保持同尺寸', value: 'same' }, + { label: '1:1', value: '1:1' }, + { label: '4:3', value: '4:3' }, + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, + { label: '3:4', value: '3:4' }, +]; +const CHARACTER_ANIMATION_DURATION_OPTIONS = [ + { label: '32帧·4秒', frameCount: 32, durationSeconds: 4 }, + { label: '40帧·5秒', frameCount: 40, durationSeconds: 5 }, + { label: '48帧·6秒', frameCount: 48, durationSeconds: 6 }, +] as const; const CANVAS_BACKGROUND_OPTIONS = [ - { label: '默认浅灰', value: DEFAULT_CANVAS_BACKGROUND_COLOR }, - { label: '黑色', value: '#000000' }, { label: '白色', value: '#ffffff' }, - { label: '绿色', value: '#00e515' }, - { label: '紫色', value: '#a66cf2' }, - { label: '淡紫', value: '#d6c5f4' }, + { label: '浅灰', value: '#f8fafc' }, + { label: '暖灰', value: '#f3f0ea' }, + { label: '冷蓝', value: '#eef6ff' }, +]; +const DEFAULT_CANVAS_BACKGROUND_COLOR = '#f8fafc'; + +function normalizeCanvasBackgroundHex(value: string) { + const trimmedValue = value.trim().toLowerCase(); + const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/u.exec(trimmedValue); + if (!match) { + return null; + } + const hexValue = match[1] ?? ''; + if (hexValue.length === 3) { + return `#${hexValue + .split('') + .map((part) => `${part}${part}`) + .join('')}`; + } + return `#${hexValue}`; +} + +const DEFAULT_SPEC_FORM_VALUES: Record = { + character: { + playSetting: '战棋类RPG玩法', + artStyle: '像素风', + bodyRatio: '3', + characterView: + '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', + customPrompt: '', + }, + ui: { + playSetting: '抓娃娃题材的抓大鹅玩法', + artStyle: '毛茸茸', + bodyRatio: '3', + characterView: + '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', + customPrompt: '', + }, + icon: { + playSetting: '休闲小游戏', + artStyle: '清爽卡通', + bodyRatio: '3', + characterView: + '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', + customPrompt: '', + }, + custom: { + playSetting: '', + artStyle: '', + bodyRatio: '3', + characterView: + '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', + customPrompt: '', + }, +}; + +const SPEC_TYPE_LABEL: Record = { + character: '角色形象规范', + ui: 'UI素材规范', + icon: '图标素材规范', + custom: '自定义规范', +}; + +const CHARACTER_SPEC_VIEW_OPTIONS = [ + DEFAULT_SPEC_FORM_VALUES.character.characterView, + '左向三分之二侧身站姿', + '左向三分之二侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯左视图,也禁止生成正面立绘。', + '右向三分之二侧身站姿,保留少量正面信息,强调面部轮廓、胸肩结构与主要装备层次。', + '背向斜侧身站姿,保留少量侧脸信息,突出背部服饰层次、武器挂载与轮廓识别。', ]; function clamp(value: number, min: number, max: number) { @@ -377,127 +604,35 @@ function formatPercent(value: number) { return `${Math.round(value * 100)}%`; } -function normalizeHexColorInput(value: string) { - const trimmedInput = value.trim().toLowerCase(); - const trimmed = trimmedInput.startsWith('#') ? trimmedInput : `#${trimmedInput}`; - const shortHexMatch = /^#([0-9a-f]{3})$/.exec(trimmed); - if (shortHexMatch?.[1]) { - const [red, green, blue] = shortHexMatch[1].split(''); - return `#${red}${red}${green}${green}${blue}${blue}`; - } - if (/^#[0-9a-f]{6}$/.test(trimmed)) { - return trimmed; - } - return null; +function formatImageSizeValue(width: number, height: number) { + const safeWidth = Math.max(1, Math.round(width || 1024)); + const safeHeight = Math.max(1, Math.round(height || 1024)); + return `${safeWidth}x${safeHeight}`; } -function sanitizeExportFilePart(value: string, fallback: string) { - const sanitized = value - .trim() - .replace(/[\\/:*?"<>|]/gu, ' ') - .replace(/\s+/gu, ' ') - .slice(0, 48) - .trim(); - return sanitized || fallback; -} - -function formatExportDate(date: Date) { - const year = String(date.getFullYear()).padStart(4, '0'); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}${month}${day}`; -} - -function getLayerExportKey(layer: CanvasLayer) { - return layer.assetObjectId || layer.objectKey || layer.sourceAssetId || layer.src; -} - -function getImageExtensionFromTypeOrSrc(type: string | undefined, src: string) { - const normalizedType = type?.toLowerCase() ?? ''; - if (normalizedType.includes('jpeg') || normalizedType.includes('jpg')) { - return 'jpg'; - } - if (normalizedType.includes('webp')) { - return 'webp'; - } - if (normalizedType.includes('gif')) { - return 'gif'; - } - const srcExtension = /\.([a-z0-9]{2,5})(?:[?#].*)?$/iu.exec(src)?.[1]?.toLowerCase(); - if (srcExtension && ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(srcExtension)) { - return srcExtension === 'jpeg' ? 'jpg' : srcExtension; - } - return 'png'; -} - -function dataUrlToBlob(dataUrl: string) { - const match = /^data:([^;,]+)?(;base64)?,(.*)$/u.exec(dataUrl); - if (!match) { - throw new Error('无法解析 data:image 素材'); - } - const mimeType = match[1] || 'application/octet-stream'; - const isBase64 = Boolean(match[2]); - const payload = match[3] ?? ''; - const binary = isBase64 ? window.atob(payload) : decodeURIComponent(payload); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return new Blob([bytes], { type: mimeType }); -} - -async function blobToUint8Array(blob: Blob) { - const arrayBuffer = - typeof blob.arrayBuffer === 'function' - ? await blob.arrayBuffer() - : await new Response(blob).arrayBuffer(); - return new Uint8Array(arrayBuffer); -} - -async function readLayerImageBlob(layer: CanvasLayer) { - if (layer.src.startsWith('data:image/')) { - return dataUrlToBlob(layer.src); - } - const response = await fetch(layer.src); - if (!response.ok) { - throw new Error(`图片读取失败:${response.status}`); - } - return response.blob(); -} - -function buildLayerExportMetadata( - layer: CanvasLayer, - file: string | null, - exportError?: string, -): CanvasAssetExportLayerMetadata { +function resolveLayerResolutionSize( + originalWidth: number, + originalHeight: number, + fallback: { width: number; height: number }, +) { + // 中文注释:画布不再维护独立展示 Size,图片显示尺寸统一跟随图片原始 Resolution。 return { - layerId: layer.id, - title: layer.title, - file, - width: layer.originalWidth, - height: layer.originalHeight, - canvas: { - x: layer.x, - y: layer.y, - width: layer.width, - height: layer.height, - zIndex: layer.zIndex, - hidden: Boolean(layer.hidden), - locked: Boolean(layer.locked), - flipX: Boolean(layer.flipX), - flipY: Boolean(layer.flipY), - groupId: layer.groupId ?? null, - }, - sourceType: layer.sourceType, - prompt: layer.prompt ?? null, - actualPrompt: layer.actualPrompt ?? null, - model: layer.model ?? null, - provider: layer.provider ?? null, - taskId: layer.taskId ?? null, - ...(exportError ? { exportError } : {}), + width: Math.max(1, Math.round(originalWidth || fallback.width || 1)), + height: Math.max(1, Math.round(originalHeight || fallback.height || 1)), }; } +function buildQuickEditSizeOptions(currentSize: string) { + return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS])); +} + +function buildQuickEditModelOptions(currentModel: string) { + const options = [...QUICK_EDIT_MODEL_OPTIONS]; + return options.some((option) => option.value === currentModel) + ? options + : [{ label: currentModel, value: currentModel }, ...options]; +} + function triggerPlaceholderAction(label: string) { window.alert(`${label}功能建设中`); } @@ -508,12 +643,18 @@ function createLayerFromAsset( viewport: CanvasViewport, screenCenter: { x: number; y: number }, ): CanvasLayer { - const longestSide = Math.max(asset.width, asset.height); - const sizeRatio = longestSide > 0 ? 360 / longestSide : 1; - const width = Math.round(asset.width * sizeRatio); - const height = Math.round(asset.height * sizeRatio); - const worldCenterX = (screenCenter.x - viewport.x) / viewport.scale; - const worldCenterY = (screenCenter.y - viewport.y) / viewport.scale; + const { width, height } = resolveLayerResolutionSize( + asset.width, + asset.height, + { width: 360, height: 360 }, + ); + const safeScale = viewport.scale > 0 ? viewport.scale : 1; + const safeScreenCenter = { + x: Number.isFinite(screenCenter.x) ? screenCenter.x : 0, + y: Number.isFinite(screenCenter.y) ? screenCenter.y : 0, + }; + const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale; + const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale; const offset = index * 34; return { @@ -563,6 +704,8 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { sourceResourceId: layer.sourceResourceId, sourceAssetId: layer.sourceAssetId, groupId: layer.groupId, + assetKind: layer.assetKind, + generationInputs: layer.generationInputs, hidden: layer.hidden, locked: layer.locked, flipX: layer.flipX, @@ -574,11 +717,13 @@ function hydrateLayer( snapshot: EditorProjectLayerSnapshot, resourcesById: Map, ): CanvasLayer | null { - const resourceId = typeof snapshot.resourceId === 'string' ? snapshot.resourceId : ''; + const resourceId = + typeof snapshot.resourceId === 'string' ? snapshot.resourceId : ''; const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : ''; const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : ''; const src = snapshotSrc || resourcesById.get(resourceId)?.imageSrc || ''; - const title = typeof snapshot.title === 'string' ? snapshot.title : '画布图片'; + const title = + typeof snapshot.title === 'string' ? snapshot.title : '画布图片'; if (!resourceId || !layerId || !src) { return null; } @@ -590,10 +735,18 @@ function hydrateLayer( src, x: numberFromSnapshot(snapshot.x, 0), y: numberFromSnapshot(snapshot.y, 0), - width: numberFromSnapshot(snapshot.width, 320), - height: numberFromSnapshot(snapshot.height, 320), - originalWidth: numberFromSnapshot(snapshot.originalWidth, 320), - originalHeight: numberFromSnapshot(snapshot.originalHeight, 320), + ...(() => { + const originalWidth = numberFromSnapshot(snapshot.originalWidth, 320); + const originalHeight = numberFromSnapshot(snapshot.originalHeight, 320); + return { + ...resolveLayerResolutionSize(originalWidth, originalHeight, { + width: numberFromSnapshot(snapshot.width, 320), + height: numberFromSnapshot(snapshot.height, 320), + }), + originalWidth, + originalHeight, + }; + })(), zIndex: numberFromSnapshot(snapshot.zIndex, 1), sourceType: isCanvasSourceType(snapshot.sourceType) ? snapshot.sourceType @@ -608,6 +761,8 @@ function hydrateLayer( sourceResourceId: stringOrNull(snapshot.sourceResourceId), sourceAssetId: stringOrNull(snapshot.sourceAssetId), groupId: stringOrNull(snapshot.groupId), + assetKind: canvasAssetKindOrNull(snapshot.assetKind), + generationInputs: generationInputsOrNull(snapshot.generationInputs), hidden: booleanFromSnapshot(snapshot.hidden), locked: booleanFromSnapshot(snapshot.locked), flipX: booleanFromSnapshot(snapshot.flipX), @@ -651,20 +806,19 @@ function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): { function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) { const mapped = mapAssetLibrarySnapshot(library); let hasDefaultFolder = false; - const normalizedFolders = mapped.folders.map((folder) => { + const normalizedFolders = mapped.folders.filter((folder) => { if (!folder.systemDefault) { - return folder; + return true; } if (hasDefaultFolder) { - return { - ...folder, - systemDefault: false, - }; + return false; } hasDefaultFolder = true; - return folder; + return true; }); - const persistedFolderIds = new Set(normalizedFolders.map((folder) => folder.id)); + const persistedFolderIds = new Set( + normalizedFolders.map((folder) => folder.id), + ); const fallbackFolders = hasDefaultFolder ? [] : EDITOR_ASSET_FOLDERS.filter((folder) => !persistedFolderIds.has(folder.id)); @@ -715,30 +869,147 @@ function resolveContextMenuPosition( }; } +function sanitizeExportFilePart(value: string, fallback: string) { + const safeValue = value + .trim() + .replace(/[\\/:*?"<>|]+/gu, ' ') + .replace(/\s+/gu, ' ') + .trim(); + return safeValue || fallback; +} + +function formatExportDate(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +} + +function getLayerExportKey(layer: CanvasLayer) { + return ( + layer.assetObjectId || + layer.objectKey || + layer.sourceAssetId || + layer.sourceResourceId || + layer.src + ); +} + +function getImageExtensionFromTypeOrSrc(type: string, src: string) { + if (type.includes('jpeg') || /\.(jpe?g)(?:[?#].*)?$/iu.test(src)) { + return 'jpg'; + } + if (type.includes('webp') || /\.webp(?:[?#].*)?$/iu.test(src)) { + return 'webp'; + } + if (type.includes('gif') || /\.gif(?:[?#].*)?$/iu.test(src)) { + return 'gif'; + } + return 'png'; +} + +function dataUrlToBlob(dataUrl: string) { + const [header = '', payload = ''] = dataUrl.split(','); + const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header); + const type = mimeMatch?.[1] ?? 'application/octet-stream'; + const isBase64 = Boolean(mimeMatch?.[2]); + const binary = isBase64 + ? typeof atob === 'function' + ? atob(payload) + : Buffer.from(payload, 'base64').toString('binary') + : decodeURIComponent(payload); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new Blob([bytes], { type }); +} + +async function readLayerImageBlob(layer: CanvasLayer) { + if (layer.src.startsWith('data:')) { + return dataUrlToBlob(layer.src); + } + const response = await fetch(layer.src); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.blob(); +} + +async function blobToUint8Array(blob: Blob) { + if (typeof blob.arrayBuffer === 'function') { + return new Uint8Array(await blob.arrayBuffer()); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (result instanceof ArrayBuffer) { + resolve(new Uint8Array(result)); + return; + } + reject(new Error('Blob 读取失败')); + }; + reader.onerror = () => reject(new Error('Blob 读取失败')); + reader.readAsArrayBuffer(blob); + }); +} + +function buildLayerExportMetadata( + layer: CanvasLayer, + file: string | null, + exportError?: string, +): CanvasAssetExportMetadata['layers'][number] { + return { + layerId: layer.id, + title: layer.title, + file, + sourceType: layer.sourceType, + prompt: layer.prompt, + actualPrompt: layer.actualPrompt, + model: layer.model, + provider: layer.provider, + taskId: layer.taskId, + objectKey: layer.objectKey, + assetObjectId: layer.assetObjectId, + sourceResourceId: layer.sourceResourceId, + sourceAssetId: layer.sourceAssetId, + exportError, + canvas: { + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + groupId: layer.groupId, + hidden: layer.hidden, + locked: layer.locked, + flipX: layer.flipX, + flipY: layer.flipY, + }, + }; +} + +function hasDataTransferType(dataTransfer: DataTransfer, type: string) { + return Array.from(dataTransfer.types).includes(type); +} + function getDraggedAssetId(dataTransfer: DataTransfer) { if (typeof dataTransfer.getData !== 'function') { return ''; } - const draggedAssetId = dataTransfer.getData(ASSET_DRAG_MIME_TYPE); - if (draggedAssetId) { - return draggedAssetId; - } if (!hasDataTransferType(dataTransfer, ASSET_DRAG_MIME_TYPE)) { return ''; } return dataTransfer.getData(ASSET_DRAG_MIME_TYPE); } -function hasDataTransferType(dataTransfer: DataTransfer, type: string) { - return Array.from(dataTransfer.types).includes(type); -} - -function isCanvasSourceType(value: unknown): value is CanvasLayer['sourceType'] { - return value === 'uploaded' || value === 'generated' || value === 'mock_generated'; -} - -function isGeneratedLayer(layer: CanvasLayer) { - return layer.sourceType === 'generated' || layer.sourceType === 'mock_generated'; +function escapeCssIdentifier(value: string) { + return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(value) + : value.replace(/["\\]/gu, '\\$&'); } function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) { @@ -750,8 +1021,308 @@ function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) { ); } -function isLayerValidForAssetLibrary(layer: CanvasLayer, _assets: EditorAsset[]) { - return layer.src.trim().length > 0; +function generationInputsOrNull(value: unknown): CanvasGenerationInputs | null { + if (!value || typeof value !== 'object') { + return null; + } + const snapshot = value as { + fields?: unknown; + references?: unknown; + }; + const fields = Array.isArray(snapshot.fields) + ? snapshot.fields.flatMap((field) => { + if (!field || typeof field !== 'object') { + return []; + } + const item = field as { title?: unknown; value?: unknown }; + const title = stringOrNull(item.title); + const fieldValue = stringOrNull(item.value); + return title && fieldValue ? [{ title, value: fieldValue }] : []; + }) + : []; + const references = Array.isArray(snapshot.references) + ? snapshot.references.flatMap((reference) => { + if (!reference || typeof reference !== 'object') { + return []; + } + const item = reference as { + title?: unknown; + label?: unknown; + src?: unknown; + }; + const title = stringOrNull(item.title); + const label = stringOrNull(item.label); + const src = stringOrNull(item.src); + return title && label && src ? [{ title, label, src }] : []; + }) + : []; + + return fields.length || references.length ? { fields, references } : null; +} + +function canvasAssetKindOrNull(value: unknown): CanvasLayer['assetKind'] { + return value === 'spec' || + value === 'character' || + value === 'icon' || + value === 'icon-spec' + ? value + : null; +} + +function isCanvasSourceType( + value: unknown, +): value is CanvasLayer['sourceType'] { + return ( + value === 'uploaded' || value === 'generated' || value === 'mock_generated' + ); +} + +function isGeneratedLayer(layer: CanvasLayer) { + return ( + layer.sourceType === 'generated' || layer.sourceType === 'mock_generated' + ); +} + +function buildCharacterSpecPrompt(values: SpecFormValues) { + return [ + '生成2D 角色美术视觉规范设定图,纯白底板,整齐排布全身标准立绘;固定统一头身比例、勾线粗细恒定;展示待机行走攻击基础动作帧样例,重心对齐不变位,服饰配饰分层结构示意,搭配专属角色色卡标注色号,无多余杂物,精准尺寸标注,高清矢量规范稿', + '禁止模糊、笔触杂乱、光影方向混乱、比例畸形、3D 渲染、实景照片、水印、花纹堆砌、画面抖动错位效果、噪点,', + `玩法设计:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.character.playSetting}`, + `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.character.artStyle}`, + `头身比:${values.bodyRatio.trim() || DEFAULT_SPEC_FORM_VALUES.character.bodyRatio}`, + `视角要求:${values.characterView.trim() || DEFAULT_SPEC_FORM_VALUES.character.characterView}`, + ].join('\n'); +} + +function buildUiSpecPrompt(values: SpecFormValues) { + return [ + '生成一张完整游戏UI规范汇总设定展板,纯白色干净背景,Figma专业设计稿质感,矢量锐利线条,页面划分九大区域:色彩规范、字体规范、图标规范、按钮规范、组件规范、布局规范、特效规范、IP规范、主视觉。主视觉居中较大显示,其他八个区域环绕主视觉', + '', + `玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.ui.playSetting}`, + `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.ui.artStyle}`, + ].join('\n'); +} + +function buildIconSpecPrompt(values: SpecFormValues) { + return [ + '生成一张游戏图标素材视觉规范展板,纯白色干净背景,展示按钮图标的统一视角、线条粗细、填充风格、描边、阴影、圆角、材质、状态层级和色彩规范,图标样例需要成组排列且风格高度统一。', + '', + `玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.icon.playSetting}`, + `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.icon.artStyle}`, + ].join('\n'); +} + +function buildSpecPrompt(type: SpecGenerationType, values: SpecFormValues) { + if (type === 'character') { + return buildCharacterSpecPrompt(values); + } + if (type === 'ui') { + return buildUiSpecPrompt(values); + } + if (type === 'icon') { + return buildIconSpecPrompt(values); + } + return values.customPrompt.trim(); +} + +function getLayerKindLabel(layer: CanvasLayer) { + if (layer.assetKind === 'spec') { + return '规范'; + } + if (layer.assetKind === 'character') { + return '角色'; + } + if (layer.assetKind === 'icon') { + return '图标'; + } + if (layer.assetKind === 'icon-spec') { + return '图标规范'; + } + return null; +} + +function formatLayerImageType(layer: CanvasLayer) { + if (layer.assetKind === 'spec') { + return '规范图片'; + } + if (layer.assetKind === 'character') { + return '角色图片'; + } + if (layer.assetKind === 'icon') { + return '图标素材图片'; + } + if (layer.assetKind === 'icon-spec') { + return '图标素材规范图片'; + } + return isGeneratedLayer(layer) ? '生成图片' : '上传图片'; +} + +function calculateCharacterAnimationPrice( + resolution: EditorCharacterAnimationResolution, + durationSeconds: number, +) { + return (resolution === '720p' ? 20 : 10) * durationSeconds; +} + +function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) { + // 中文注释:角色图已持久化到 OSS 时优先传 objectKey,避免把大 Data URL 塞进 JSON 请求体触发 body limit。 + return layer.objectKey?.trim() || layer.src; +} + +function createCanvasLayerReference( + layer: CanvasLayer, +): CharacterReferenceImage { + return { + id: `canvas-${layer.id}`, + label: layer.title, + src: layer.src, + }; +} + +function createGenerationInputField( + title: string, + value: string | null | undefined, +): CanvasGenerationInputField[] { + const normalizedValue = value?.trim(); + return normalizedValue ? [{ title, value: normalizedValue }] : []; +} + +function buildImageGenerationInputs(prompt: string): CanvasGenerationInputs { + return { + fields: createGenerationInputField('生成提示词', prompt), + references: [], + }; +} + +function buildSpecGenerationInputs( + specType: SpecGenerationType, + values: SpecFormValues, +): CanvasGenerationInputs { + if (specType === 'custom') { + return { + fields: createGenerationInputField('自定义规范提示词', values.customPrompt), + references: [], + }; + } + + const baseFields = [ + ...createGenerationInputField('玩法设定', values.playSetting), + ...createGenerationInputField('美术风格', values.artStyle), + ]; + if (specType === 'character') { + baseFields.push( + ...createGenerationInputField('头身比', values.bodyRatio), + ...createGenerationInputField('角色视角', values.characterView), + ); + } + return { + fields: baseFields, + references: [], + }; +} + +function buildCharacterGenerationInputs( + prompt: string, + specReference: CharacterReferenceImage | null | undefined, + references: CharacterReferenceImage[] | undefined, +): CanvasGenerationInputs { + return { + fields: createGenerationInputField('角色设定', prompt), + references: [ + ...(specReference + ? [ + { + title: '角色形象规范', + label: specReference.label, + src: specReference.src, + }, + ] + : []), + ...(references ?? []).map((reference, index) => ({ + title: `常规参考图 ${index + 1}`, + label: reference.label, + src: reference.src, + })), + ], + }; +} + +function buildIconGenerationInputs( + iconDescriptions: string[], + specReference: CharacterReferenceImage, +): CanvasGenerationInputs { + return { + fields: iconDescriptions.map((description, index) => ({ + title: `素材描述 ${index + 1}`, + value: description, + })), + references: [ + { + title: '图标素材规范', + label: specReference.label, + src: specReference.src, + }, + ], + }; +} + +function buildEditGenerationInputs( + title: '修改要求' | '快速编辑提示词', + prompt: string, + sourceLayer: CanvasLayer, +): CanvasGenerationInputs { + return { + fields: createGenerationInputField(title, prompt), + references: [ + { + title: '参考图', + label: sourceLayer.title, + src: sourceLayer.src, + }, + ], + }; +} + +function buildPortalMenuStyle( + anchor: HTMLElement | null, + placement: 'above' | 'below', +): CSSProperties { + const rect = anchor?.getBoundingClientRect(); + if (!rect) { + return { + position: 'fixed', + left: 0, + top: 0, + right: 'auto', + bottom: 'auto', + zIndex: 70, + }; + } + + return { + position: 'fixed', + left: Math.round(rect.left), + top: + placement === 'above' + ? Math.round(rect.top) + : Math.round(rect.bottom + 8), + right: 'auto', + bottom: 'auto', + zIndex: 70, + transform: + placement === 'above' ? 'translateY(calc(-100% - 0.45rem))' : undefined, + }; +} + +function renderEditorPortal(node: ReactNode) { + if (typeof document === 'undefined') { + return node; + } + return createPortal(node, document.body); +} + +function isImageFile(file: File) { + return file.type.startsWith('image/'); } function getLayerBounds(targetLayers: CanvasLayer[]) { @@ -832,6 +1403,44 @@ function getPointerId(event: ReactPointerEvent) { return Number.isFinite(nativeId) ? nativeId : -1; } +function isCanvasGenerationDialog( + dialog: GenerateDialogState | null, +): dialog is CanvasGenerationDialogState { + return Boolean( + dialog?.id && + (dialog.mode === 'generate' || + dialog.mode === 'spec' || + dialog.mode === 'character' || + dialog.mode === 'icon'), + ); +} + +function getGenerationFrameAriaLabel(dialog: CanvasGenerationDialogState) { + if (dialog.mode === 'character') { + return '角色生成占位图'; + } + if (dialog.mode === 'spec') { + return '规范生成占位图'; + } + if (dialog.mode === 'icon') { + return '图标素材生成占位图'; + } + return '图像生成占位图'; +} + +function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) { + if (dialog.mode === 'character') { + return 'Character Generator'; + } + if (dialog.mode === 'spec') { + return 'Spec Generator'; + } + if (dialog.mode === 'icon') { + return 'Icon Generator'; + } + return 'Image Generator'; +} + function resolveImageGenerationErrorMessage(error: unknown) { if ( error instanceof ApiClientError && @@ -845,6 +1454,13 @@ function resolveImageGenerationErrorMessage(error: unknown) { : '生成图片失败'; } +function isEditorAuthError(error: unknown) { + return ( + error instanceof ApiClientError && + (error.status === 401 || error.status === 403) + ); +} + export function ImageCanvasEditorView() { const authUi = useAuthUi(); const editorRootRef = useRef(null); @@ -852,38 +1468,47 @@ export function ImageCanvasEditorView() { const uploadInputRef = useRef(null); const assetListRef = useRef(null); const dragStateRef = useRef(null); + const assetPointerDragRef = useRef(null); + const authUiRef = useRef(authUi); const isShiftPressedRef = useRef(false); const layerCounterRef = useRef(0); + const generationDialogCounterRef = useRef(0); const saveTimerRef = useRef(null); const undoStackRef = useRef([]); const redoStackRef = useRef([]); const layersRef = useRef([]); - const assetsRef = useRef([]); - const assetPointerDragRef = useRef(null); - const addAssetLayerRef = useRef< - (asset: EditorAsset, position?: { x: number; y: number }) => void - >(() => {}); - const moveAssetToFolderRef = useRef< - (assetId: string, folderId: string) => void - >(() => {}); - const suppressAssetClickRef = useRef(false); const viewportRef = useRef({ x: -260, y: 70, scale: 0.82, }); - const generateDialogRef = useRef(null); + const projectIdRef = useRef(null); + const specToolWrapRef = useRef(null); + const characterSpecButtonRef = useRef(null); + const iconSpecButtonRef = useRef(null); + const pendingProjectResourceLayersRef = useRef< + Array<{ + layer: CanvasLayer; + options: { onCreated?: (resourceId: string) => void }; + }> + >([]); const selectedLayerIdRef = useRef(null); const selectedLayerIdsRef = useRef([]); - const dragHistoryCapturedRef = useRef(false); + const generateDialogRef = useRef(null); + const inactiveGenerateDialogsRef = useRef([]); + const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>( + () => {}, + ); + const suppressAssetClickRef = useRef(false); const [projectId, setProjectId] = useState(null); const [projectTitle, setProjectTitle] = useState('未命名画布'); const [projectRenameValue, setProjectRenameValue] = useState('未命名画布'); const [isRenamingProject, setIsRenamingProject] = useState(false); const [isProjectRenameSaving, setIsProjectRenameSaving] = useState(false); - const [projectRenameError, setProjectRenameError] = useState(null); + const [projectRenameError, setProjectRenameError] = useState( + null, + ); const [isProjectReady, setIsProjectReady] = useState(false); - const [isAssetLibraryReady, setIsAssetLibraryReady] = useState(false); const [assetExportStatus, setAssetExportStatus] = useState<{ tone: 'info' | 'success' | 'error'; message: string; @@ -919,6 +1544,14 @@ export function ImageCanvasEditorView() { const [assetMarquee, setAssetMarquee] = useState( null, ); + const [assetPointerDrag, setAssetPointerDrag] = + useState(null); + const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState< + string | null + >(null); + const [pinnedAssetMoveFolderId, setPinnedAssetMoveFolderId] = useState< + string | null + >(null); const [canvasMarquee, setCanvasMarquee] = useState( null, ); @@ -930,40 +1563,91 @@ export function ImageCanvasEditorView() { const [isPanning, setIsPanning] = useState(false); const [snapGuide, setSnapGuide] = useState(null); const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false); - const [isBackgroundPanelOpen, setIsBackgroundPanelOpen] = useState(false); + const [isBackgroundSettingsOpen, setIsBackgroundSettingsOpen] = + useState(false); + const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isMinimapOpen, setIsMinimapOpen] = useState(true); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState( DEFAULT_CANVAS_BACKGROUND_COLOR, ); - const [backgroundHexValue, setBackgroundHexValue] = useState( + const [canvasBackgroundHexValue, setCanvasBackgroundHexValue] = useState( DEFAULT_CANVAS_BACKGROUND_COLOR, ); const [metadataLayer, setMetadataLayer] = useState(null); const [generateDialog, setGenerateDialog] = useState(null); - const [historyVersion, setHistoryVersion] = useState(0); - const [canvasClipboard, setCanvasClipboard] = - useState(null); + const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState< + CanvasGenerationDialogState[] + >([]); + const [uploadTarget, setUploadTarget] = useState('asset'); + const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false); + const [ + isPickingCharacterSpecFromCanvas, + setIsPickingCharacterSpecFromCanvas, + ] = useState(false); + const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false); + const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = + useState(false); + const [imageContextMenu, setImageContextMenu] = + useState(null); const [contextMenu, setContextMenu] = useState(null); - const [uploadDropTarget, setUploadDropTarget] = - useState(null); - const [assetMoveDropFolderId, setAssetMoveDropFolderId] = - useState(null); - const [isAssetMoveDropHeaderPinned, setIsAssetMoveDropHeaderPinned] = - useState(false); - const [assetPointerDrag, setAssetPointerDrag] = - useState(null); + const [canvasClipboard, setCanvasClipboard] = + useState(null); + const [historyVersion, setHistoryVersion] = useState(0); + const [quickEditPanel, setQuickEditPanel] = + useState(null); + const [characterAnimationPanel, setCharacterAnimationPanel] = + useState(null); + const [uploadDropTarget, setUploadDropTarget] = useState< + 'canvas' | 'assets' | null + >(null); - const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool; - const applyCanvasBackgroundColor = useCallback((value: string) => { - const normalizedColor = normalizeHexColorInput(value); + selectedLayerIdRef.current = selectedLayerId; + selectedLayerIdsRef.current = selectedLayerIds; + layersRef.current = layers; + viewportRef.current = viewport; + generateDialogRef.current = generateDialog; + inactiveGenerateDialogsRef.current = inactiveGenerateDialogs; + const assetsRef = useRef(assets); + const addAssetLayerRef = useRef< + (asset: EditorAsset, screenCenter?: { x: number; y: number }) => void + >(() => {}); + const moveAssetToFolderRef = useRef< + (assetId: string, folderId: string) => void + >(() => {}); + authUiRef.current = authUi; + const openEditorLoginModal = useCallback( + (postLoginAction?: (() => void) | null) => { + authUiRef.current?.openLoginModal(postLoginAction); + }, + [], + ); + const applyCanvasBackgroundColor = useCallback((color: string) => { + const normalizedColor = normalizeCanvasBackgroundHex(color); if (!normalizedColor) { - return; + return false; } setCanvasBackgroundColor(normalizedColor); - setBackgroundHexValue(normalizedColor); + setCanvasBackgroundHexValue(normalizedColor); + return true; }, []); + + useEffect(() => { + assetsRef.current = assets; + }, [assets]); + + const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool; + const activeCanvasGenerationDialog = isCanvasGenerationDialog(generateDialog) + ? generateDialog + : null; + const canvasGenerationDialogs = useMemo( + () => + activeCanvasGenerationDialog + ? [...inactiveGenerateDialogs, activeCanvasGenerationDialog] + : inactiveGenerateDialogs, + [activeCanvasGenerationDialog, inactiveGenerateDialogs], + ); const selectedLayer = useMemo( () => layers.find((layer) => layer.id === selectedLayerId) ?? null, [layers, selectedLayerId], @@ -972,27 +1656,50 @@ export function ImageCanvasEditorView() { const hasMultipleSelectedLayers = selectedLayerCount > 1; const activeGenerationLayer = useMemo( () => - generateDialog?.mode === 'generate' && generateDialog.generatedLayerId - ? layers.find((layer) => layer.id === generateDialog.generatedLayerId) ?? null + activeCanvasGenerationDialog?.generatedLayerId + ? (layers.find( + (layer) => + layer.id === activeCanvasGenerationDialog.generatedLayerId, + ) ?? null) : null, - [generateDialog, layers], + [activeCanvasGenerationDialog, layers], ); const generationAnchor = - generateDialog?.mode === 'generate' - ? (activeGenerationLayer ?? generateDialog.placeholder ?? null) + activeCanvasGenerationDialog + ? (activeGenerationLayer ?? + activeCanvasGenerationDialog.placeholder ?? + null) : null; const generationComposerStyle = - generateDialog?.composerOpen !== false && generationAnchor - ? { - left: - viewport.x + - (generationAnchor.x + generationAnchor.width / 2) * viewport.scale, - top: - viewport.y + - (generationAnchor.y + generationAnchor.height) * viewport.scale + - 10, - } - : null; + activeCanvasGenerationDialog?.status !== 'generating' && + activeCanvasGenerationDialog?.composerOpen !== false && + generationAnchor + ? { + left: + viewport.x + + (generationAnchor.x + generationAnchor.width / 2) * viewport.scale, + top: + viewport.y + + (generationAnchor.y + generationAnchor.height) * viewport.scale + + 10, + } + : null; + const iconDescriptionValues = + activeCanvasGenerationDialog?.mode === 'icon' + ? (activeCanvasGenerationDialog.iconDescriptions ?? + DEFAULT_ICON_DESCRIPTIONS) + : DEFAULT_ICON_DESCRIPTIONS; + const iconComposerStyle: CSSProperties | null = + activeCanvasGenerationDialog?.mode === 'icon' && generationComposerStyle + ? { + ...generationComposerStyle, + width: `${Math.max( + ICON_COMPOSER_MIN_WIDTH_REM, + ICON_COMPOSER_HORIZONTAL_CHROME_REM + + iconDescriptionValues.length * ICON_DESCRIPTION_CARD_WIDTH_REM, + ).toFixed(1)}rem`, + } + : null; const selectedToolbarStyle = selectedLayer ? { left: clamp( @@ -1005,6 +1712,93 @@ export function ImageCanvasEditorView() { top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12), } : null; + const characterAnimationSourceLayer = characterAnimationPanel + ? (layers.find( + (layer) => layer.id === characterAnimationPanel.sourceLayerId, + ) ?? null) + : null; + const quickEditSourceLayer = quickEditPanel + ? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ?? + null) + : null; + const quickEditPanelStyle = + quickEditPanel && quickEditSourceLayer + ? { + left: clamp( + viewport.x + + (quickEditSourceLayer.x + quickEditSourceLayer.width / 2) * + viewport.scale, + 12, + Math.max(12, canvasSize.width - 12), + ), + top: clamp( + viewport.y + + (quickEditSourceLayer.y + quickEditSourceLayer.height) * + viewport.scale + + 12, + 12, + Math.max(12, canvasSize.height - 360), + ), + } + : null; + const quickEditSizeOptions = quickEditPanel + ? buildQuickEditSizeOptions(quickEditPanel.size) + : []; + const quickEditModelOptions = quickEditPanel + ? buildQuickEditModelOptions(quickEditPanel.model) + : []; + const characterAnimationPrice = characterAnimationPanel + ? calculateCharacterAnimationPrice( + characterAnimationPanel.resolution, + characterAnimationPanel.durationSeconds, + ) + : 0; + const characterAnimationPanelStyle = + characterAnimationPanel && characterAnimationSourceLayer + ? { + left: clamp( + viewport.x + + (characterAnimationSourceLayer.x + + characterAnimationSourceLayer.width) * + viewport.scale + + 12, + 12, + Math.max(12, canvasSize.width - 364), + ), + top: clamp( + viewport.y + characterAnimationSourceLayer.y * viewport.scale, + 12, + Math.max(12, canvasSize.height - 520), + ), + } + : null; + const imageContextMenuLayer = imageContextMenu + ? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null) + : null; + const getContextTargetLayerIds = useCallback( + (menu: CanvasContextMenuState | null = contextMenu) => { + if (menu?.kind !== 'layer') { + return []; + } + return selectedLayerIdsRef.current.includes(menu.layerId) + ? selectedLayerIdsRef.current + : [menu.layerId]; + }, + [contextMenu], + ); + const contextTargetIds = getContextTargetLayerIds(contextMenu); + const contextTargetLayers = layers.filter((layer) => + contextTargetIds.includes(layer.id), + ); + const contextShouldShowLayer = contextTargetLayers.some( + (layer) => layer.hidden, + ); + const contextShouldUnlockLayer = contextTargetLayers.some( + (layer) => layer.locked, + ); + const canUndo = undoStackRef.current.length > 0; + const canRedo = redoStackRef.current.length > 0; + void historyVersion; const groupedAssets = useMemo( () => assetFolders.map((folder) => ({ @@ -1020,37 +1814,108 @@ export function ImageCanvasEditorView() { const allSelectableAssetsSelected = selectableAssets.length > 0 && selectableAssets.every((asset) => selectedAssetIds.has(asset.id)); - const assetMoveDropFolder = useMemo( - () => - assetMoveDropFolderId - ? groupedAssets.find((folder) => folder.id === assetMoveDropFolderId) ?? null - : null, - [assetMoveDropFolderId, groupedAssets], - ); - useEffect(() => { - layersRef.current = layers; - }, [layers]); + const createGenerationDialogId = () => { + generationDialogCounterRef.current += 1; + return `generation-dialog-${generationDialogCounterRef.current}`; + }; - useEffect(() => { - assetsRef.current = assets; - }, [assets]); + const archiveActiveCanvasGenerationDialog = () => { + const currentDialog = generateDialogRef.current; + if (!isCanvasGenerationDialog(currentDialog)) { + return; + } + setInactiveGenerateDialogs((currentDialogs) => + currentDialogs.some((dialog) => dialog.id === currentDialog.id) + ? currentDialogs + : [ + ...currentDialogs, + { + ...currentDialog, + composerOpen: false, + }, + ], + ); + }; - useEffect(() => { - viewportRef.current = viewport; - }, [viewport]); + const openCanvasGenerationDialog = ( + dialog: Omit, + ) => { + archiveActiveCanvasGenerationDialog(); + setGenerateDialog({ + ...dialog, + id: createGenerationDialogId(), + }); + }; - useEffect(() => { - generateDialogRef.current = generateDialog; - }, [generateDialog]); + const updateCanvasGenerationDialogById = ( + dialogId: string, + updater: ( + dialog: CanvasGenerationDialogState, + ) => CanvasGenerationDialogState | null, + ) => { + setGenerateDialog((currentDialog) => + isCanvasGenerationDialog(currentDialog) && + currentDialog.id === dialogId + ? updater(currentDialog) + : currentDialog, + ); + setInactiveGenerateDialogs((currentDialogs) => + currentDialogs.flatMap((dialog) => { + if (dialog.id !== dialogId) { + return [dialog]; + } + const nextDialog = updater(dialog); + return nextDialog ? [nextDialog] : []; + }), + ); + }; - useEffect(() => { - selectedLayerIdRef.current = selectedLayerId; - }, [selectedLayerId]); + const removeCanvasGenerationDialogById = (dialogId: string) => { + updateCanvasGenerationDialogById(dialogId, () => null); + }; - useEffect(() => { - selectedLayerIdsRef.current = selectedLayerIds; - }, [selectedLayerIds]); + const activateCanvasGenerationDialog = ( + targetDialog: CanvasGenerationDialogState, + ) => { + setInactiveGenerateDialogs((currentDialogs) => { + const nextDialogs = currentDialogs.filter( + (dialog) => dialog.id !== targetDialog.id, + ); + const currentDialog = generateDialogRef.current; + if ( + isCanvasGenerationDialog(currentDialog) && + currentDialog.id !== targetDialog.id + ) { + nextDialogs.push({ + ...currentDialog, + composerOpen: false, + }); + } + return nextDialogs; + }); + setGenerateDialog({ + ...targetDialog, + composerOpen: true, + }); + setSelectedLayerId(null); + setSelectedLayerIds([]); + setImageContextMenu(null); + }; + + const removeCanvasGenerationDialogsByLayerId = (targetLayerId: string) => { + const keepDialog = (dialog: CanvasGenerationDialogState) => + dialog.sourceLayerId !== targetLayerId && + dialog.generatedLayerId !== targetLayerId; + setGenerateDialog((currentDialog) => + isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) + ? null + : currentDialog, + ); + setInactiveGenerateDialogs((currentDialogs) => + currentDialogs.filter(keepDialog), + ); + }; const getCanvasHistorySnapshot = useCallback( (): CanvasHistorySnapshot => ({ @@ -1064,6 +1929,12 @@ export function ImageCanvasEditorView() { : undefined, } : null, + inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map( + (dialog) => ({ + ...dialog, + placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, + }), + ), selectedLayerId: selectedLayerIdRef.current, selectedLayerIds: [...selectedLayerIdsRef.current], }), @@ -1084,12 +1955,20 @@ export function ImageCanvasEditorView() { } : null, ); + setInactiveGenerateDialogs( + snapshot.inactiveGenerateDialogs.map((dialog) => ({ + ...dialog, + placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, + })), + ); setSelectedLayerId(snapshot.selectedLayerId); setSelectedLayerIds([...snapshot.selectedLayerIds]); setHoveredLayerId(null); setMetadataLayer(null); setCanvasMarquee(null); setSnapGuide(null); + setImageContextMenu(null); + setContextMenu(null); setIsPanning(false); dragStateRef.current = null; }, @@ -1138,16 +2017,15 @@ export function ImageCanvasEditorView() { setHistoryVersion((version) => version + 1); }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); - const canUndo = undoStackRef.current.length > 0; - const canRedo = redoStackRef.current.length > 0; - void historyVersion; - - const selectSingleLayer = (layerId: string | null) => { + const selectSingleLayer = useCallback((layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); - if (layerId && generateDialog?.mode === 'generate') { + if (layerId) { setGenerateDialog((currentDialog) => - currentDialog?.mode === 'generate' + currentDialog?.mode === 'generate' || + currentDialog?.mode === 'spec' || + currentDialog?.mode === 'character' || + currentDialog?.mode === 'icon' ? { ...currentDialog, composerOpen: false, @@ -1155,7 +2033,100 @@ export function ImageCanvasEditorView() { : currentDialog, ); } - }; + }, []); + + const hideGeneratedLayerPanelAfterBlur = useCallback(() => { + setGenerateDialog((currentDialog) => + (currentDialog?.mode === 'generate' || + currentDialog?.mode === 'spec' || + currentDialog?.mode === 'character' || + currentDialog?.mode === 'icon') && + currentDialog.status !== 'generating' + ? { + ...currentDialog, + composerOpen: false, + } + : currentDialog, + ); + }, []); + + const clearCanvasFocus = useCallback(() => { + selectSingleLayer(null); + hideGeneratedLayerPanelAfterBlur(); + setImageContextMenu(null); + setContextMenu(null); + }, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]); + + const getGeneratingDialogPlaceholder = useCallback( + (dialog: GenerateDialogState) => { + const currentDialog = generateDialogRef.current; + if (dialog.id) { + const latestDialog = [ + ...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []), + ...inactiveGenerateDialogsRef.current, + ].find((candidateDialog) => candidateDialog.id === dialog.id); + if (latestDialog?.status === 'generating') { + return latestDialog.placeholder ?? dialog.placeholder; + } + } + if ( + currentDialog?.mode === dialog.mode && + (!dialog.id || currentDialog.id === dialog.id) && + currentDialog.status === 'generating' + ) { + return currentDialog.placeholder ?? dialog.placeholder; + } + return dialog.placeholder; + }, + [], + ); + + const createProjectResourceForLayer = useCallback( + ( + layer: CanvasLayer, + options: { onCreated?: (resourceId: string) => void } = {}, + ) => { + const readyProjectId = projectIdRef.current; + if (!readyProjectId) { + pendingProjectResourceLayersRef.current.push({ layer, options }); + return; + } + createEditorProjectResource(readyProjectId, { + imageSrc: layer.src, + objectKey: layer.objectKey, + assetObjectId: layer.assetObjectId, + width: layer.originalWidth, + height: layer.originalHeight, + sourceType: layer.sourceType, + prompt: layer.prompt, + actualPrompt: layer.actualPrompt, + model: layer.model, + provider: layer.provider, + taskId: layer.taskId, + sourceResourceId: layer.sourceResourceId, + }) + .then((resource) => { + options.onCreated?.(resource.resourceId); + setLayers((currentLayers) => + currentLayers.map((currentLayer) => + currentLayer.id === layer.id + ? { + ...currentLayer, + resourceId: resource.resourceId, + } + : currentLayer, + ), + ); + }) + .catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); + }, + [openEditorLoginModal], + ); + const minimapModel = useMemo(() => { const layerBounds = getLayerBounds(layers); if (!layerBounds) { @@ -1214,8 +2185,9 @@ export function ImageCanvasEditorView() { const projectIdFromQuery = typeof window === 'undefined' ? null - : new URLSearchParams(window.location.search).get('projectid')?.trim() || - null; + : new URLSearchParams(window.location.search) + .get('projectid') + ?.trim() || null; const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) : loadOrCreateRecentEditorProject(); @@ -1225,10 +2197,15 @@ export function ImageCanvasEditorView() { if (cancelled) { return; } + projectIdRef.current = project.projectId; setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; setProjectTitle(nextProjectTitle); setProjectRenameValue(nextProjectTitle); + const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); + pendingLayers.forEach(({ layer, options }) => { + createProjectResourceForLayer(layer, options); + }); setViewport(project.viewport); const resourcesById = new Map( project.resources.map((resource) => [ @@ -1239,27 +2216,29 @@ export function ImageCanvasEditorView() { const hydratedLayers = project.layers .map((layer) => hydrateLayer(layer, resourcesById)) .filter((layer): layer is CanvasLayer => Boolean(layer)); - layerCounterRef.current = hydratedLayers.length; - setLayers(hydratedLayers); - selectSingleLayer(hydratedLayers[0]?.id ?? null); - undoStackRef.current = []; - redoStackRef.current = []; - setHistoryVersion((version) => version + 1); + if (hydratedLayers.length > 0) { + layerCounterRef.current = hydratedLayers.length; + setLayers(hydratedLayers); + selectSingleLayer(hydratedLayers[0]?.id ?? null); + } setIsProjectReady(true); }) .catch((error: unknown) => { - if (!cancelled) { - if (error instanceof ApiClientError && error.status === 401) { - authUi?.openLoginModal(() => window.location.reload()); - } - setIsProjectReady(false); + if (cancelled) { + return; + } + setIsProjectReady(false); + if (isEditorAuthError(error)) { + openEditorLoginModal(() => { + window.location.reload(); + }); } }); return () => { cancelled = true; }; - }, [authUi]); + }, [createProjectResourceForLayer, openEditorLoginModal, selectSingleLayer]); useEffect(() => { let cancelled = false; @@ -1274,14 +2253,19 @@ export function ImageCanvasEditorView() { const defaultFolder = nextLibrary.folders.find( (folder) => folder.systemDefault, ); - setActiveUploadFolderId(defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project'); - setIsAssetLibraryReady(true); + setActiveUploadFolderId( + defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project', + ); }) - .catch(() => setIsAssetLibraryReady(true)); + .catch((error: unknown) => { + if (!cancelled && isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); return () => { cancelled = true; }; - }, []); + }, [openEditorLoginModal]); useEffect(() => { const viewportElement = canvasViewportRef.current; @@ -1308,11 +2292,13 @@ export function ImageCanvasEditorView() { return () => observer.disconnect(); }, []); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isHistoryShortcut = - !isEditableTarget(event) && (event.ctrlKey || event.metaKey); - if (isHistoryShortcut && event.key.toLowerCase() === 'z') { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + (event.ctrlKey || event.metaKey) && + event.code === 'KeyZ' && + !isEditableTarget(event) + ) { event.preventDefault(); if (event.shiftKey) { redoCanvasChange(); @@ -1321,29 +2307,74 @@ export function ImageCanvasEditorView() { } return; } - if (isHistoryShortcut && event.key.toLowerCase() === 'y') { - event.preventDefault(); - redoCanvasChange(); - return; - } - if (event.key === 'Shift') { - isShiftPressedRef.current = true; + if (event.key === 'Shift') { + isShiftPressedRef.current = true; + } + if ( + (event.key === 'Backspace' || event.key === 'Delete') && + !event.repeat && + !isEditableTarget(event) + ) { + const currentDialog = generateDialogRef.current; + const currentSelectedLayerId = selectedLayerIdRef.current; + if (currentSelectedLayerId) { + event.preventDefault(); + deleteLayerByIdRef.current(currentSelectedLayerId); + return; + } + if ( + currentDialog?.placeholder && + currentDialog.status !== 'generating' && + (currentDialog.mode === 'generate' || + currentDialog.mode === 'spec' || + currentDialog.mode === 'character' || + currentDialog.mode === 'icon') + ) { + event.preventDefault(); + setGenerateDialog(null); + setActiveTool('select'); + setIsCharacterSpecMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + setIsIconSpecMenuOpen(false); + setIsPickingIconSpecFromCanvas(false); + return; + } } if (event.key === 'Escape') { setActiveSidebarPanel(null); setIsZoomMenuOpen(false); - setIsBackgroundPanelOpen(false); + setIsBackgroundSettingsOpen(false); + setIsSpecMenuOpen(false); + setImageContextMenu(null); setContextMenu(null); - setGenerateDialog((currentDialog) => - currentDialog?.status === 'generating' - ? currentDialog - : currentDialog?.mode === 'generate' - ? { - ...currentDialog, - composerOpen: false, - } - : null, + setQuickEditPanel((currentPanel) => + currentPanel?.status === 'generating' ? currentPanel : null, ); + setIsCharacterSpecMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + setIsIconSpecMenuOpen(false); + setIsPickingIconSpecFromCanvas(false); + setGenerateDialog((currentDialog) => { + if (!currentDialog || currentDialog.status === 'generating') { + return currentDialog; + } + if ( + currentDialog.mode === 'generate' || + currentDialog.mode === 'spec' + ) { + return { + ...currentDialog, + composerOpen: false, + }; + } + if (currentDialog.mode === 'character') { + return currentDialog; + } + if (currentDialog.mode === 'icon') { + return currentDialog; + } + return null; + }); return; } if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) { @@ -1371,139 +2402,6 @@ export function ImageCanvasEditorView() { }; }, [redoCanvasChange, undoCanvasChange]); - useEffect(() => { - if (!contextMenu) { - return undefined; - } - const closeContextMenu = (event: PointerEvent) => { - const target = event.target; - if ( - target instanceof Element && - target.closest('.image-canvas-editor__context-menu') - ) { - return; - } - setContextMenu(null); - }; - window.addEventListener('pointerdown', closeContextMenu); - return () => window.removeEventListener('pointerdown', closeContextMenu); - }, [contextMenu]); - - useEffect(() => { - const resolveCanvasPoint = (clientX: number, clientY: number) => { - const rect = canvasViewportRef.current?.getBoundingClientRect(); - if ( - !rect || - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - return null; - } - return { - x: clientX - rect.left, - y: clientY - rect.top, - }; - }; - const resolveAssetFolderId = (clientX: number, clientY: number) => { - const listElement = assetListRef.current; - if (!listElement) { - return null; - } - const listRect = listElement.getBoundingClientRect(); - if ( - clientX < listRect.left || - clientX > listRect.right || - clientY < listRect.top || - clientY > listRect.bottom - ) { - return null; - } - const folderElements = [ - ...listElement.querySelectorAll('[data-asset-folder-id]'), - ]; - const matchedFolder = folderElements.find((element) => { - const rect = element.getBoundingClientRect(); - return ( - clientX >= rect.left && - clientX <= rect.right && - clientY >= rect.top && - clientY <= rect.bottom - ); - }); - return matchedFolder?.dataset.assetFolderId ?? null; - }; - - const updatePointerDrag = (event: PointerEvent) => { - const currentDrag = assetPointerDragRef.current; - if (!currentDrag || currentDrag.pointerId !== event.pointerId) { - return; - } - const distance = Math.hypot( - event.clientX - currentDrag.startClientX, - event.clientY - currentDrag.startClientY, - ); - const nextDrag = { - ...currentDrag, - currentClientX: event.clientX, - currentClientY: event.clientY, - active: currentDrag.active || distance > 4, - dropFolderId: resolveAssetFolderId(event.clientX, event.clientY), - }; - assetPointerDragRef.current = nextDrag; - setAssetPointerDrag(nextDrag); - const canvasPoint = resolveCanvasPoint(event.clientX, event.clientY); - setUploadDropTarget(canvasPoint ? 'canvas' : null); - updateAssetMoveDropFolder(nextDrag.dropFolderId); - }; - - const finishPointerDrag = (event: PointerEvent) => { - const currentDrag = assetPointerDragRef.current; - if (!currentDrag || currentDrag.pointerId !== event.pointerId) { - return; - } - const canvasPoint = resolveCanvasPoint(event.clientX, event.clientY); - const dropFolderId = - resolveAssetFolderId(event.clientX, event.clientY) ?? - currentDrag.dropFolderId; - const draggedAsset = assetsRef.current.find( - (asset) => asset.id === currentDrag.assetId, - ); - assetPointerDragRef.current = null; - setAssetPointerDrag(null); - setUploadDropTarget(null); - updateAssetMoveDropFolder(null); - if (!currentDrag.active || !draggedAsset) { - return; - } - if (dropFolderId && dropFolderId !== draggedAsset.folderId) { - suppressAssetClickRef.current = true; - window.setTimeout(() => { - suppressAssetClickRef.current = false; - }, 0); - moveAssetToFolderRef.current(draggedAsset.id, dropFolderId); - return; - } - if (canvasPoint) { - suppressAssetClickRef.current = true; - window.setTimeout(() => { - suppressAssetClickRef.current = false; - }, 0); - addAssetLayerRef.current(draggedAsset, canvasPoint); - } - }; - - window.addEventListener('pointermove', updatePointerDrag); - window.addEventListener('pointerup', finishPointerDrag); - window.addEventListener('pointercancel', finishPointerDrag); - return () => { - window.removeEventListener('pointermove', updatePointerDrag); - window.removeEventListener('pointerup', finishPointerDrag); - window.removeEventListener('pointercancel', finishPointerDrag); - }; - }, []); - useEffect(() => { const blockBrowserZoom = (event: WheelEvent) => { const editorElement = editorRootRef.current; @@ -1526,29 +2424,72 @@ export function ImageCanvasEditorView() { }, []); useEffect(() => { - if (!isProjectReady || !isAssetLibraryReady) { - return; - } - const invalidLayerIds = layers - .filter((layer) => !isLayerValidForAssetLibrary(layer, assets)) - .map((layer) => layer.id); - if (!invalidLayerIds.length) { - return; - } - const invalidLayerIdSet = new Set(invalidLayerIds); - setLayers((currentLayers) => - currentLayers.filter((layer) => !invalidLayerIdSet.has(layer.id)), - ); - setSelectedLayerIds((currentIds) => - currentIds.filter((layerId) => !invalidLayerIdSet.has(layerId)), - ); - setSelectedLayerId((currentId) => - currentId && invalidLayerIdSet.has(currentId) ? null : currentId, - ); - setMetadataLayer((currentLayer) => - currentLayer && invalidLayerIdSet.has(currentLayer.id) ? null : currentLayer, - ); - }, [assets, isAssetLibraryReady, isProjectReady, layers]); + const updatePointerDrag = (event: PointerEvent) => { + const currentDrag = assetPointerDragRef.current; + if (!currentDrag || currentDrag.pointerId !== event.pointerId) { + return; + } + const distance = Math.hypot( + event.clientX - currentDrag.startClientX, + event.clientY - currentDrag.startClientY, + ); + const dropFolderId = resolveAssetFolderId(event.clientX, event.clientY); + const nextDrag: AssetPointerDragState = { + ...currentDrag, + currentClientX: event.clientX, + currentClientY: event.clientY, + active: currentDrag.active || distance > 4, + dropFolderId, + }; + assetPointerDragRef.current = nextDrag; + setAssetPointerDrag(nextDrag); + setUploadDropTarget( + resolveCanvasPoint(event.clientX, event.clientY) ? 'canvas' : null, + ); + updateAssetMoveDropFolder(dropFolderId); + }; + + const finishPointerDrag = (event: PointerEvent) => { + const currentDrag = assetPointerDragRef.current; + if (!currentDrag || currentDrag.pointerId !== event.pointerId) { + return; + } + const canvasPoint = resolveCanvasPoint(event.clientX, event.clientY); + const dropFolderId = + resolveAssetFolderId(event.clientX, event.clientY) ?? + currentDrag.dropFolderId; + const draggedAsset = assetsRef.current.find( + (asset) => asset.id === currentDrag.assetId, + ); + assetPointerDragRef.current = null; + setAssetPointerDrag(null); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + if (!currentDrag.active || !draggedAsset) { + return; + } + suppressAssetClickRef.current = true; + window.setTimeout(() => { + suppressAssetClickRef.current = false; + }, 0); + if (dropFolderId && dropFolderId !== draggedAsset.folderId) { + moveAssetToFolderRef.current(draggedAsset.id, dropFolderId); + return; + } + if (canvasPoint) { + addAssetLayerRef.current(draggedAsset, canvasPoint); + } + }; + + window.addEventListener('pointermove', updatePointerDrag); + window.addEventListener('pointerup', finishPointerDrag); + window.addEventListener('pointercancel', finishPointerDrag); + return () => { + window.removeEventListener('pointermove', updatePointerDrag); + window.removeEventListener('pointerup', finishPointerDrag); + window.removeEventListener('pointercancel', finishPointerDrag); + }; + }, []); useEffect(() => { if (!projectId || !isProjectReady) { @@ -1562,7 +2503,11 @@ export function ImageCanvasEditorView() { saveEditorProjectLayout(projectId, { viewport, layers: layers.map(serializeLayer), - }).catch(() => {}); + }).catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + }); }, 450); return () => { @@ -1570,7 +2515,7 @@ export function ImageCanvasEditorView() { window.clearTimeout(saveTimerRef.current); } }; - }, [isProjectReady, layers, projectId, viewport]); + }, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]); const fitLayers = useCallback( (targetLayers: CanvasLayer[] = layers) => { @@ -1584,44 +2529,50 @@ export function ImageCanvasEditorView() { } const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); - const availableWidth = Math.max(1, canvasSize.width - FIT_VIEW_PADDING * 2); - const availableHeight = Math.max(1, canvasSize.height - FIT_VIEW_PADDING * 2); - const scale = clamp( - Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight), + const availableWidth = Math.max( + 1, + canvasSize.width - FIT_VIEW_PADDING * 2, + ); + const availableHeight = Math.max( + 1, + canvasSize.height - FIT_VIEW_PADDING * 2, + ); + const scale = clamp( + Math.min( + 1, + availableWidth / boundsWidth, + availableHeight / boundsHeight, + ), MIN_SCALE, MAX_SCALE, - ); + ); captureCanvasHistory(); - setViewport({ - x: - canvasSize.width / 2 - - (bounds.minX + boundsWidth / 2) * scale, - y: - canvasSize.height / 2 - - (bounds.minY + boundsHeight / 2) * scale, + setViewport({ + x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale, + y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale, scale, }); - }, + }, [captureCanvasHistory, canvasSize.height, canvasSize.width, layers], - ); + ); - const updateScaleFromCenter = (nextScale: number) => { - const viewportElement = canvasViewportRef.current; - if (!viewportElement) { + const updateScaleFromCenter = (nextScale: number) => { + const viewportElement = canvasViewportRef.current; + if (!viewportElement) { captureCanvasHistory(); - setViewport((currentViewport) => ({ + setViewport((currentViewport) => ({ ...currentViewport, scale: clamp(nextScale, MIN_SCALE, MAX_SCALE), })); return; } - const rect = viewportElement.getBoundingClientRect(); + const rect = viewportElement.getBoundingClientRect(); const centerX = rect.width > 0 ? rect.width / 2 : canvasSize.width / 2; - const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2; + const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2; captureCanvasHistory(); - setViewport((currentViewport) => { + setViewport((currentViewport) => { const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE); const worldX = (centerX - currentViewport.x) / currentViewport.scale; const worldY = (centerY - currentViewport.y) / currentViewport.scale; @@ -1633,6 +2584,31 @@ export function ImageCanvasEditorView() { }); }; + const resolveCanvasPoint = (clientX: number, clientY: number) => { + const rect = canvasViewportRef.current?.getBoundingClientRect(); + if (!rect) { + return null; + } + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null; + } + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }; + + const getCanvasDropPoint = (event: ReactDragEvent) => + resolveCanvasPoint(event.clientX, event.clientY) ?? { + x: Number.isFinite(canvasSize.width) ? canvasSize.width / 2 : 0, + y: Number.isFinite(canvasSize.height) ? canvasSize.height / 2 : 0, + }; + const getCanvasPointFromClient = (clientX: number, clientY: number) => { const rect = canvasViewportRef.current?.getBoundingClientRect(); const screenX = clientX - (rect?.left ?? 0); @@ -1643,20 +2619,10 @@ export function ImageCanvasEditorView() { }; }; - const getContextTargetLayerIds = ( - menu: CanvasContextMenuState | null = contextMenu, - ) => { - if (menu?.kind !== 'layer') { - return []; - } - return selectedLayerIds.includes(menu.layerId) - ? selectedLayerIds - : [menu.layerId]; - }; - const duplicateLayersToPoint = ( sourceLayers: CanvasLayer[], canvasPoint?: { x: number; y: number }, + options: { renameCopies?: boolean } = {}, ) => { if (!sourceLayers.length) { return []; @@ -1667,11 +2633,12 @@ export function ImageCanvasEditorView() { (maxZ, layer) => Math.max(maxZ, layer.zIndex), 0, ); + const stamp = Date.now(); return sourceLayers.map((layer, index) => ({ ...layer, - id: `layer-copy-${Date.now()}-${index}`, - resourceId: `local-resource-copy-${Date.now()}-${index}`, - title: `${layer.title} 副本`, + id: `layer-copy-${stamp}-${index}`, + resourceId: `local-resource-copy-${stamp}-${index}`, + title: options.renameCopies === false ? layer.title : `${layer.title} 副本`, x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32, y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32, zIndex: maxZIndex + index + 1, @@ -1683,7 +2650,9 @@ export function ImageCanvasEditorView() { if (!canvasClipboard?.layers.length) { return; } - const nextLayers = duplicateLayersToPoint(canvasClipboard.layers, canvasPoint); + const nextLayers = duplicateLayersToPoint(canvasClipboard.layers, canvasPoint, { + renameCopies: canvasClipboard.mode !== 'cut', + }); if (!nextLayers.length) { return; } @@ -1703,6 +2672,7 @@ export function ImageCanvasEditorView() { } setCanvasClipboard({ layers: targetLayers.map((layer) => ({ ...layer })), + mode: options.cut ? 'cut' : 'copy', }); if (options.cut) { captureCanvasHistory(); @@ -1711,7 +2681,9 @@ export function ImageCanvasEditorView() { ); selectSingleLayer(null); setMetadataLayer((currentLayer) => - currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, + currentLayer && targetIds.includes(currentLayer.id) + ? null + : currentLayer, ); } setContextMenu(null); @@ -1752,8 +2724,14 @@ export function ImageCanvasEditorView() { if (!targetIds.length) { return; } - const maxZIndex = layers.reduce((maxZ, layer) => Math.max(maxZ, layer.zIndex), 0); - const minZIndex = layers.reduce((minZ, layer) => Math.min(minZ, layer.zIndex), 0); + const maxZIndex = layers.reduce( + (maxZ, layer) => Math.max(maxZ, layer.zIndex), + 0, + ); + const minZIndex = layers.reduce( + (minZ, layer) => Math.min(minZ, layer.zIndex), + 0, + ); let offsetIndex = 0; updateContextLayers((layer) => { if (mode === 'up') { @@ -1825,12 +2803,116 @@ export function ImageCanvasEditorView() { ); }; + const deleteContextLayers = () => { + const targetIds = getContextTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + currentLayers.filter((layer) => !targetIds.includes(layer.id)), + ); + selectSingleLayer(null); + setHoveredLayerId(null); + setMetadataLayer((currentLayer) => + currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, + ); + setContextMenu(null); + }; + + const exportContextLayer = () => { + const targetIds = getContextTargetLayerIds(); + const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); + if (!targetLayer) { + return; + } + const link = document.createElement('a'); + link.href = targetLayer.src; + link.download = `${sanitizeExportFilePart(targetLayer.title, 'canvas-layer')}.png`; + document.body.appendChild(link); + link.click(); + link.remove(); + setContextMenu(null); + }; + + const resolveAssetFolderId = (clientX: number, clientY: number) => { + const listElement = assetListRef.current; + if (!listElement) { + return null; + } + const listRect = listElement.getBoundingClientRect(); + if ( + clientX < listRect.left || + clientX > listRect.right || + clientY < listRect.top || + clientY > listRect.bottom + ) { + return null; + } + const folderElements = [ + ...listElement.querySelectorAll('[data-asset-folder-id]'), + ]; + const matchedFolder = folderElements.find((element) => { + const rect = element.getBoundingClientRect(); + return ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ); + }); + return matchedFolder?.dataset.assetFolderId ?? null; + }; + + const updateAssetMoveDropFolder = (folderId: string | null) => { + setAssetMoveDropFolderId(folderId); + if (!folderId) { + setPinnedAssetMoveFolderId(null); + return; + } + const listElement = assetListRef.current; + const header = listElement?.querySelector( + `[data-asset-folder-header-id="${escapeCssIdentifier(folderId)}"]`, + ); + const listRect = listElement?.getBoundingClientRect(); + const headerRect = header?.getBoundingClientRect(); + setPinnedAssetMoveFolderId( + listRect && headerRect && + (headerRect.bottom < listRect.top || headerRect.top > listRect.bottom) + ? folderId + : null, + ); + }; + + const addAssetLayer = ( + asset: EditorAsset, + position?: { x: number; y: number }, + ) => { + setActiveUploadFolderId(asset.folderId); + layerCounterRef.current += 1; + const nextLayer = createLayerFromAsset( + asset, + layerCounterRef.current, + viewport, + { + x: position?.x ?? canvasSize.width / 2, + y: position?.y ?? canvasSize.height / 2, + }, + ); + captureCanvasHistory(); + setLayers((currentLayers) => [...currentLayers, nextLayer]); + selectSingleLayer(nextLayer.id); + setHoveredLayerId(null); + createProjectResourceForLayer(nextLayer); + }; + addAssetLayerRef.current = addAssetLayer; + const exportCanvasAssets = async () => { if (isExportingAssets) { return; } const exportableLayers = layers - .filter((layer) => isLayerValidForAssetLibrary(layer, assets)) + .filter((layer) => layer.src.trim().length > 0) .sort((left, right) => left.zIndex - right.zIndex); if (!exportableLayers.length) { setAssetExportStatus({ @@ -1864,7 +2946,9 @@ export function ImageCanvasEditorView() { const duplicateCount = usedFileNames.get(baseFileName) ?? 0; usedFileNames.set(baseFileName, duplicateCount + 1); const indexedFileName = - duplicateCount > 0 ? `${baseFileName}-${duplicateCount + 1}` : baseFileName; + duplicateCount > 0 + ? `${baseFileName}-${duplicateCount + 1}` + : baseFileName; try { const blob = await readLayerImageBlob(layer); @@ -1876,7 +2960,10 @@ export function ImageCanvasEditorView() { layer, blob, }); - imagesFolder.file(`${indexedFileName}.${extension}`, await blobToUint8Array(blob)); + imagesFolder.file( + `${indexedFileName}.${extension}`, + await blobToUint8Array(blob), + ); } catch (error) { imageByKey.set(key, { key, @@ -1887,8 +2974,12 @@ export function ImageCanvasEditorView() { } } - const failedImages = [...imageByKey.values()].filter((image) => image.error); - const successfulImages = [...imageByKey.values()].filter((image) => image.blob); + const failedImages = [...imageByKey.values()].filter( + (image) => image.error, + ); + const successfulImages = [...imageByKey.values()].filter( + (image) => image.blob, + ); if (!successfulImages.length) { setAssetExportStatus({ tone: 'error', @@ -1963,21 +3054,6 @@ export function ImageCanvasEditorView() { } }; - const exportContextLayer = () => { - const targetIds = getContextTargetLayerIds(); - const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); - if (!targetLayer) { - return; - } - const link = document.createElement('a'); - link.href = targetLayer.src; - link.download = `${targetLayer.title || 'canvas-layer'}.png`; - document.body.appendChild(link); - link.click(); - link.remove(); - setContextMenu(null); - }; - const startProjectRename = () => { setProjectRenameValue(projectTitle); setProjectRenameError(null); @@ -2012,6 +3088,9 @@ export function ImageCanvasEditorView() { setIsRenamingProject(false); }) .catch((error: unknown) => { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } setProjectRenameError( error instanceof Error ? error.message : '重命名项目失败', ); @@ -2019,259 +3098,6 @@ export function ImageCanvasEditorView() { .finally(() => setIsProjectRenameSaving(false)); }; - const deleteContextLayers = () => { - const targetIds = getContextTargetLayerIds(); - if (!targetIds.length) { - return; - } - captureCanvasHistory(); - setLayers((currentLayers) => - currentLayers.filter((layer) => !targetIds.includes(layer.id)), - ); - selectSingleLayer(null); - setHoveredLayerId(null); - setMetadataLayer((currentLayer) => - currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, - ); - setContextMenu(null); - }; - - const updateAssetMoveDropFolder = (folderId: string | null) => { - setAssetMoveDropFolderId(folderId); - if (!folderId) { - setIsAssetMoveDropHeaderPinned(false); - return; - } - const listElement = assetListRef.current; - const headerElement = - [...(listElement?.querySelectorAll( - '[data-asset-folder-header-id]', - ) ?? [])].find( - (element) => element.dataset.assetFolderHeaderId === folderId, - ) ?? null; - if (!listElement || !headerElement) { - setIsAssetMoveDropHeaderPinned(false); - return; - } - const listRect = listElement.getBoundingClientRect(); - const headerRect = headerElement.getBoundingClientRect(); - setIsAssetMoveDropHeaderPinned( - headerRect.top < listRect.top || headerRect.bottom > listRect.bottom, - ); - }; - - const saveProjectLayoutNow = useCallback( - (nextLayers: CanvasLayer[], nextViewport = viewportRef.current) => { - if (!projectId || !isProjectReady) { - return Promise.resolve(); - } - if (saveTimerRef.current) { - window.clearTimeout(saveTimerRef.current); - saveTimerRef.current = null; - } - return saveEditorProjectLayout(projectId, { - viewport: nextViewport, - layers: nextLayers.map(serializeLayer), - }); - }, - [isProjectReady, projectId], - ); - - const createProjectResourceForLayer = ( - layer: CanvasLayer, - options: { - onCreated?: (resourceId: string) => void; - onFailed?: () => void; - saveLayout?: boolean; - } = {}, - ) => { - if (!projectId) { - options.onFailed?.(); - return Promise.resolve(null); - } - return createEditorProjectResource(projectId, { - imageSrc: layer.src, - objectKey: layer.objectKey, - assetObjectId: layer.assetObjectId, - width: layer.originalWidth, - height: layer.originalHeight, - sourceType: layer.sourceType, - prompt: layer.prompt, - actualPrompt: layer.actualPrompt, - model: layer.model, - provider: layer.provider, - taskId: layer.taskId, - sourceResourceId: layer.sourceResourceId, - }) - .then((resource) => { - options.onCreated?.(resource.resourceId); - setLayers((currentLayers) => { - const nextLayers = currentLayers.map((currentLayer) => - currentLayer.id === layer.id - ? { - ...currentLayer, - resourceId: resource.resourceId, - } - : currentLayer, - ); - return nextLayers; - }); - if (options.saveLayout) { - const latestLayers = layersRef.current.map((currentLayer) => - currentLayer.id === layer.id - ? { - ...currentLayer, - resourceId: resource.resourceId, - } - : currentLayer, - ); - void saveProjectLayoutNow(latestLayers).catch(() => {}); - } - return resource; - }) - .catch(() => { - options.onFailed?.(); - return null; - }); - }; - - const createAssetFromGeneratedLayer = (layer: CanvasLayer) => { - const targetFolder = - assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; - if (!targetFolder) { - return Promise.resolve(null); - } - const temporaryAssetId = `generated-asset-${layer.id}`; - const temporaryAsset: EditorAsset = { - id: temporaryAssetId, - label: layer.title, - src: layer.src, - width: layer.originalWidth, - height: layer.originalHeight, - folderId: targetFolder.id, - sourceKind: 'uploaded', - sourceType: layer.sourceType, - persisted: false, - prompt: layer.prompt ?? undefined, - actualPrompt: layer.actualPrompt ?? undefined, - model: layer.model ?? undefined, - provider: layer.provider ?? undefined, - taskId: layer.taskId ?? undefined, - objectKey: layer.objectKey ?? undefined, - assetObjectId: layer.assetObjectId ?? undefined, - }; - - setAssets((currentAssets) => [...currentAssets, temporaryAsset]); - setAssetFolders((currentFolders) => - currentFolders.map((folder) => - folder.id === targetFolder.id - ? { - ...folder, - collapsed: false, - } - : folder, - ), - ); - - return createEditorAsset({ - folderId: targetFolder.id, - label: layer.title, - imageSrc: layer.src, - width: layer.originalWidth, - height: layer.originalHeight, - sourceType: layer.sourceType, - prompt: layer.prompt, - actualPrompt: layer.actualPrompt, - model: layer.model, - provider: layer.provider, - taskId: layer.taskId, - objectKey: layer.objectKey, - assetObjectId: layer.assetObjectId, - }) - .then((asset) => { - setAssets((currentAssets) => - currentAssets.map((currentAsset) => - currentAsset.id === temporaryAssetId - ? { - ...currentAsset, - id: asset.assetId, - folderId: asset.folderId, - label: asset.label, - src: asset.imageSrc, - width: asset.width, - height: asset.height, - sourceType: asset.sourceType, - prompt: asset.prompt ?? undefined, - actualPrompt: asset.actualPrompt ?? undefined, - model: asset.model ?? undefined, - provider: asset.provider ?? undefined, - taskId: asset.taskId ?? undefined, - objectKey: asset.objectKey ?? undefined, - assetObjectId: asset.assetObjectId ?? undefined, - persisted: true, - } - : currentAsset, - ), - ); - setLayers((currentLayers) => { - const nextLayers = currentLayers.map((currentLayer) => - currentLayer.id === layer.id - ? { - ...currentLayer, - sourceAssetId: asset.assetId, - objectKey: asset.objectKey ?? currentLayer.objectKey, - assetObjectId: asset.assetObjectId ?? currentLayer.assetObjectId, - } - : currentLayer, - ); - void saveProjectLayoutNow(nextLayers).catch(() => {}); - return nextLayers; - }); - return asset; - }) - .catch(() => { - setAssets((currentAssets) => - currentAssets.filter((asset) => asset.id !== temporaryAssetId), - ); - return null; - }); - }; - - const addAssetLayer = (asset: EditorAsset, position?: { x: number; y: number }) => { - setActiveUploadFolderId(asset.folderId); - layerCounterRef.current += 1; - const nextLayer = createLayerFromAsset( - asset, - layerCounterRef.current, - viewport, - { - x: position?.x ?? canvasSize.width / 2, - y: position?.y ?? canvasSize.height / 2, - }, - ); - captureCanvasHistory(); - setLayers((currentLayers) => { - return [...currentLayers, nextLayer]; - }); - selectSingleLayer(nextLayer.id); - setHoveredLayerId(null); - createProjectResourceForLayer(nextLayer, { - onFailed: () => { - setLayers((currentLayers) => - currentLayers.filter((currentLayer) => currentLayer.id !== nextLayer.id), - ); - setSelectedLayerIds((currentIds) => - currentIds.filter((layerId) => layerId !== nextLayer.id), - ); - setSelectedLayerId((currentId) => - currentId === nextLayer.id ? null : currentId, - ); - }, - saveLayout: true, - }); - }; - addAssetLayerRef.current = addAssetLayer; - const startRenamingAsset = (asset: EditorAsset) => { setRenamingAsset({ assetId: asset.id, @@ -2301,49 +3127,6 @@ export function ImageCanvasEditorView() { setRenamingAsset(null); }; - const moveAssetToFolder = (assetId: string, folderId: string) => { - const movingAsset = assets.find((asset) => asset.id === assetId); - if (!movingAsset || movingAsset.folderId === folderId) { - return; - } - const previousFolderId = movingAsset.folderId; - setAssets((currentAssets) => - currentAssets.map((asset) => - asset.id === assetId - ? { - ...asset, - folderId, - } - : asset, - ), - ); - setAssetFolders((currentFolders) => - currentFolders.map((folder) => - folder.id === folderId - ? { - ...folder, - collapsed: false, - } - : folder, - ), - ); - if (movingAsset.persisted) { - updateEditorAsset(assetId, { folderId }).catch(() => { - setAssets((currentAssets) => - currentAssets.map((asset) => - asset.id === assetId - ? { - ...asset, - folderId: previousFolderId, - } - : asset, - ), - ); - }); - } - }; - moveAssetToFolderRef.current = moveAssetToFolder; - const toggleAssetFolder = (folderId: string) => { const nextFolder = assetFolders.find((folder) => folder.id === folderId); const nextCollapsed = !(nextFolder?.collapsed ?? false); @@ -2358,7 +3141,9 @@ export function ImageCanvasEditorView() { ), ); if (nextFolder?.persisted) { - updateEditorAssetFolder(folderId, { collapsed: nextCollapsed }).catch(() => {}); + updateEditorAssetFolder(folderId, { collapsed: nextCollapsed }).catch( + () => {}, + ); } }; @@ -2384,7 +3169,10 @@ export function ImageCanvasEditorView() { setCreatingFolder(false); setNewFolderName(''); try { - const folder = await createEditorAssetFolder(label, assetFolders.length + 100); + const folder = await createEditorAssetFolder( + label, + assetFolders.length + 100, + ); setAssetFolders((currentFolders) => currentFolders.map((currentFolder) => currentFolder.id === folderId @@ -2418,7 +3206,6 @@ export function ImageCanvasEditorView() { if (asset.sourceKind !== 'uploaded') { return; } - captureCanvasHistory(); setAssets((currentAssets) => currentAssets.filter((currentAsset) => currentAsset.id !== asset.id), ); @@ -2426,24 +3213,21 @@ export function ImageCanvasEditorView() { currentLayers.filter((layer) => !isLayerLinkedToAsset(layer, asset)), ); setSelectedLayerIds((currentIds) => - currentIds.filter( - (layerId) => - !layersRef.current.some( - (layer) => layer.id === layerId && isLayerLinkedToAsset(layer, asset), - ), + currentIds.filter((layerId) => + layers.every( + (layer) => layer.id !== layerId || !isLayerLinkedToAsset(layer, asset), + ), ), ); - setSelectedLayerId((currentId) => - currentId && - layersRef.current.some( - (layer) => layer.id === currentId && isLayerLinkedToAsset(layer, asset), - ) + setSelectedLayerId((currentId) => { + if (!currentId) { + return currentId; + } + const currentLayer = layers.find((layer) => layer.id === currentId); + return currentLayer && isLayerLinkedToAsset(currentLayer, asset) ? null - : currentId, - ); - setMetadataLayer((currentLayer) => - currentLayer && isLayerLinkedToAsset(currentLayer, asset) ? null : currentLayer, - ); + : currentId; + }); setRenamingAsset((currentRename) => currentRename?.assetId === asset.id ? null : currentRename, ); @@ -2537,43 +3321,15 @@ export function ImageCanvasEditorView() { const deleteSelectedAssets = () => { const ids = [...selectedAssetIds]; - const deletingAssets = assets.filter((asset) => selectedAssetIds.has(asset.id)); - if (deletingAssets.length) { - captureCanvasHistory(); - } + const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id)); setAssets((currentAssets) => currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)), ); setLayers((currentLayers) => currentLayers.filter( - (layer) => !deletingAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), - ), - ); - setSelectedLayerIds((currentIds) => - currentIds.filter( - (layerId) => - !layersRef.current.some( - (layer) => - layer.id === layerId && - deletingAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), - ), - ), - ); - setSelectedLayerId((currentId) => - currentId && - layersRef.current.some( (layer) => - layer.id === currentId && - deletingAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), - ) - ? null - : currentId, - ); - setMetadataLayer((currentLayer) => - currentLayer && - deletingAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset)) - ? null - : currentLayer, + !deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), + ), ); setSelectedAssetIds(new Set()); ids.forEach((assetId) => { @@ -2581,6 +3337,28 @@ export function ImageCanvasEditorView() { }); }; + const moveAssetToFolder = (assetId: string, folderId: string) => { + const asset = assets.find((currentAsset) => currentAsset.id === assetId); + if (!asset || asset.folderId === folderId) { + return; + } + setAssets((currentAssets) => + currentAssets.map((currentAsset) => + currentAsset.id === assetId + ? { + ...currentAsset, + folderId, + } + : currentAsset, + ), + ); + if (asset.persisted) { + updateEditorAsset(asset.id, { folderId }).catch(() => {}); + } + }; + + moveAssetToFolderRef.current = moveAssetToFolder; + const closeAssetSelectionMode = () => { setIsAssetSelectionMode(false); setSelectedAssetIds(new Set()); @@ -2601,7 +3379,9 @@ export function ImageCanvasEditorView() { if (!assetId) { return; } - const asset = assets.find((currentAsset) => currentAsset.id === assetId); + const asset = assets.find( + (currentAsset) => currentAsset.id === assetId, + ); if (!asset || asset.sourceKind !== 'uploaded') { return; } @@ -2625,10 +3405,7 @@ export function ImageCanvasEditorView() { return; } const target = event.target as HTMLElement; - if (target.closest('input, textarea, select')) { - return; - } - if (target.closest('button') && !target.closest('[data-asset-id]')) { + if (target.closest('button, input, textarea, select, [data-asset-id]')) { return; } event.preventDefault(); @@ -2656,10 +3433,8 @@ export function ImageCanvasEditorView() { const containerRect = assetListRef.current?.getBoundingClientRect(); const currentX = event.clientX - (containerRect?.left ?? 0); const currentY = event.clientY - (containerRect?.top ?? 0); - const startClientX = - (containerRect?.left ?? 0) + assetMarquee.startX; - const startClientY = - (containerRect?.top ?? 0) + assetMarquee.startY; + const startClientX = (containerRect?.left ?? 0) + assetMarquee.startX; + const startClientY = (containerRect?.top ?? 0) + assetMarquee.startY; setAssetMarquee((currentMarquee) => currentMarquee ? { @@ -2704,27 +3479,122 @@ export function ImageCanvasEditorView() { reader.readAsDataURL(file); }); - const updateUploadingAsset = ( - assetId: string, - patch: Partial< - Pick< - EditorAsset, - 'uploadProgress' | 'uploadStatus' | 'uploadMessage' | 'src' | 'width' | 'height' - > - >, - ) => { - setAssets((currentAssets) => - currentAssets.map((asset) => - asset.id === assetId - ? { - ...asset, - ...patch, - } - : asset, - ), + const setCharacterGenerationIdle = (dialog: GenerateDialogState) => ({ + ...dialog, + status: dialog.status === 'failed' ? 'idle' : dialog.status, + errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, + }); + + const addCharacterSpecReferenceFiles = async (files: FileList | File[]) => { + const imageFile = Array.from(files).find(isImageFile); + if (!imageFile) { + window.alert('请选择图片文件'); + return; + } + + const imageSrc = await readImageFileAsDataUrl(imageFile); + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'character' + ? { + ...setCharacterGenerationIdle(currentDialog), + characterSpecReference: { + id: `upload-character-spec-${Date.now()}`, + label: imageFile.name || '角色形象规范', + src: imageSrc, + }, + } + : currentDialog, ); }; + const addCharacterReferenceFiles = async (files: FileList | File[]) => { + const imageFiles = Array.from(files).filter(isImageFile); + if (!imageFiles.length) { + window.alert('请选择图片文件'); + return; + } + + const references = await Promise.all( + imageFiles.map(async (file, index) => ({ + id: `upload-character-reference-${Date.now()}-${index}`, + label: file.name || `参考图${index + 1}`, + src: await readImageFileAsDataUrl(file), + })), + ); + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'character' + ? { + ...setCharacterGenerationIdle(currentDialog), + characterReferences: [ + ...(currentDialog.characterReferences ?? []), + ...references, + ], + } + : currentDialog, + ); + }; + + const pickCharacterSpecFromLayer = (layer: CanvasLayer) => { + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'character' + ? { + ...setCharacterGenerationIdle(currentDialog), + characterSpecReference: createCanvasLayerReference(layer), + composerOpen: true, + } + : currentDialog, + ); + setIsPickingCharacterSpecFromCanvas(false); + setIsCharacterSpecMenuOpen(false); + setImageContextMenu(null); + }; + + const setIconGenerationIdle = (dialog: GenerateDialogState) => ({ + ...dialog, + status: dialog.status === 'failed' ? 'idle' : dialog.status, + errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, + }); + + const addIconSpecReferenceFiles = async (files: FileList | File[]) => { + const imageFile = Array.from(files).find(isImageFile); + if (!imageFile) { + window.alert('请选择图片文件'); + return; + } + + const imageSrc = await readImageFileAsDataUrl(imageFile); + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'icon' + ? { + ...setIconGenerationIdle(currentDialog), + iconSpecReference: { + id: `upload-icon-spec-${Date.now()}`, + label: imageFile.name || '图标素材规范', + src: imageSrc, + }, + } + : currentDialog, + ); + }; + + const pickIconSpecFromLayer = (layer: CanvasLayer) => { + if (layer.assetKind !== 'icon-spec') { + return; + } + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'icon' + ? { + ...setIconGenerationIdle(currentDialog), + iconSpecReference: createCanvasLayerReference(layer), + composerOpen: true, + } + : currentDialog, + ); + setIsPickingIconSpecFromCanvas(false); + setIsIconSpecMenuOpen(false); + setImageContextMenu(null); + }; + const addUploadedLayer = async ( file: File, options: { @@ -2739,14 +3609,15 @@ export function ImageCanvasEditorView() { return; } - const uploadIndex = options.uploadIndex ?? layerCounterRef.current + 1; - layerCounterRef.current = Math.max(layerCounterRef.current, uploadIndex); const fallbackWidth = 420; const fallbackHeight = 315; - const uploadFolderId = - assetFolders.some((folder) => folder.id === (options.folderId ?? activeUploadFolderId)) - ? (options.folderId ?? activeUploadFolderId) - : 'project'; + const uploadFolderId = assetFolders.some( + (folder) => folder.id === (options.folderId ?? activeUploadFolderId), + ) + ? (options.folderId ?? activeUploadFolderId) + : 'project'; + const uploadIndex = options.uploadIndex ?? layerCounterRef.current + 1; + layerCounterRef.current = Math.max(layerCounterRef.current, uploadIndex); const uploadedAsset: EditorAsset = { id: `upload-${uploadIndex}`, label: file.name || '上传图片', @@ -2776,20 +3647,33 @@ export function ImageCanvasEditorView() { let imageSrc = ''; try { imageSrc = await readImageFileAsDataUrl(file); - updateUploadingAsset(uploadedAsset.id, { - src: imageSrc, - uploadProgress: 42, - uploadMessage: '读取图片', - }); + setAssets((currentAssets) => + currentAssets.map((asset) => + asset.id === uploadedAsset.id + ? { + ...asset, + src: imageSrc, + uploadProgress: 42, + uploadMessage: '读取图片', + } + : asset, + ), + ); } catch { - updateUploadingAsset(uploadedAsset.id, { - uploadStatus: 'failed', - uploadProgress: 100, - uploadMessage: '读取失败', - }); + setAssets((currentAssets) => + currentAssets.map((asset) => + asset.id === uploadedAsset.id + ? { + ...asset, + uploadStatus: 'failed', + uploadProgress: 100, + uploadMessage: '读取失败', + } + : asset, + ), + ); return; } - const screenPoint = options.canvasPoint ?? { x: canvasSize.width / 2, y: canvasSize.height / 2, @@ -2818,6 +3702,7 @@ export function ImageCanvasEditorView() { originalHeight: fallbackHeight, zIndex: uploadIndex + 10, sourceType: 'uploaded', + sourceAssetId: `upload-${uploadIndex}`, }; if (options.addToCanvas) { @@ -2828,10 +3713,17 @@ export function ImageCanvasEditorView() { setActiveSidebarPanel('layers'); } - updateUploadingAsset(uploadedAsset.id, { - uploadProgress: 68, - uploadMessage: '上传中', - }); + setAssets((currentAssets) => + currentAssets.map((asset) => + asset.id === uploadedAsset.id + ? { + ...asset, + uploadProgress: 68, + uploadMessage: '上传中', + } + : asset, + ), + ); createEditorAsset({ folderId: uploadFolderId, label: uploadedAsset.label, @@ -2863,44 +3755,42 @@ export function ImageCanvasEditorView() { ), ); if (options.addToCanvas) { - setLayers((currentLayers) => { - const nextLayers = currentLayers.map((currentLayer) => + setLayers((currentLayers) => + currentLayers.map((currentLayer) => currentLayer.id === nextLayer.id ? { ...currentLayer, sourceAssetId: asset.assetId, objectKey: asset.objectKey ?? currentLayer.objectKey, - assetObjectId: asset.assetObjectId ?? currentLayer.assetObjectId, + assetObjectId: + asset.assetObjectId ?? currentLayer.assetObjectId, } : currentLayer, - ); - return nextLayers; - }); + ), + ); } }) - .catch(() => { - updateUploadingAsset(uploadedAsset.id, { - uploadStatus: 'failed', - uploadProgress: 100, - uploadMessage: '上传失败', - }); + .catch((error: unknown) => { + const isAuthError = isEditorAuthError(error); + if (isAuthError) { + openEditorLoginModal(); + } + setAssets((currentAssets) => + currentAssets.map((asset) => + asset.id === uploadedAsset.id + ? { + ...asset, + uploadStatus: 'failed', + uploadProgress: 100, + uploadMessage: isAuthError ? '请先登录' : '上传失败', + } + : asset, + ), + ); }); if (options.addToCanvas) { - createProjectResourceForLayer(nextLayer, { - onFailed: () => { - setLayers((currentLayers) => - currentLayers.filter((currentLayer) => currentLayer.id !== nextLayer.id), - ); - setSelectedLayerIds((currentIds) => - currentIds.filter((layerId) => layerId !== nextLayer.id), - ); - setSelectedLayerId((currentId) => - currentId === nextLayer.id ? null : currentId, - ); - }, - saveLayout: true, - }); + createProjectResourceForLayer(nextLayer); } if (imageSrc) { @@ -2908,10 +3798,11 @@ export function ImageCanvasEditorView() { uploadedImage.onload = () => { const originalWidth = uploadedImage.naturalWidth || fallbackWidth; const originalHeight = uploadedImage.naturalHeight || fallbackHeight; - const longestSide = Math.max(originalWidth, originalHeight); - const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1; - const width = Math.round(originalWidth * sizeRatio); - const height = Math.round(originalHeight * sizeRatio); + const { width, height } = resolveLayerResolutionSize( + originalWidth, + originalHeight, + { width: fallbackWidth, height: fallbackHeight }, + ); if (options.addToCanvas) { setLayers((currentLayers) => currentLayers.map((layer) => @@ -2953,10 +3844,15 @@ export function ImageCanvasEditorView() { addToCanvas?: boolean; } = {}, ) => { - if (options.addToCanvas) { - captureCanvasHistory(); + const imageFiles = Array.from(files); + const currentAuthUi = authUiRef.current; + if (currentAuthUi && !currentAuthUi.canAccessProtectedData) { + openEditorLoginModal(() => { + addUploadedFiles(imageFiles, options); + }); + return; } - Array.from(files).forEach((file, index) => { + imageFiles.forEach((file, index) => { layerCounterRef.current += 1; const uploadIndex = layerCounterRef.current; void addUploadedLayer(file, { @@ -2973,19 +3869,61 @@ export function ImageCanvasEditorView() { }); }; + const deleteLayerById = (targetLayerId: string | null) => { + if (!targetLayerId) { + return; + } + setImageContextMenu(null); + setContextMenu(null); + captureCanvasHistory(); + setLayers((currentLayers) => { + const nextLayers = currentLayers.filter( + (layer) => layer.id !== targetLayerId, + ); + const nextSelectedLayer = nextLayers + .slice() + .sort((left, right) => right.zIndex - left.zIndex)[0]; + selectSingleLayer(nextSelectedLayer?.id ?? null); + return nextLayers; + }); + setHoveredLayerId(null); + setMetadataLayer((currentLayer) => + currentLayer?.id === targetLayerId ? null : currentLayer, + ); + setQuickEditPanel((currentPanel) => + currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, + ); + setCharacterAnimationPanel((currentPanel) => + currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, + ); + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'edit' && + currentDialog.sourceLayerId === targetLayerId + ? null + : currentDialog, + ); + removeCanvasGenerationDialogsByLayerId(targetLayerId); + }; + + deleteLayerByIdRef.current = deleteLayerById; + const deleteSelectedLayer = () => { const targetIds = selectedLayerIds.length ? selectedLayerIds : selectedLayerId ? [selectedLayerId] : []; - if (!targetIds.length) { + if (targetIds.length <= 1) { + deleteLayerById(targetIds[0] ?? null); return; } captureCanvasHistory(); + setImageContextMenu(null); + setContextMenu(null); setLayers((currentLayers) => { - const targetIdSet = new Set(targetIds); - const nextLayers = currentLayers.filter((layer) => !targetIdSet.has(layer.id)); + const nextLayers = currentLayers.filter( + (layer) => !targetIds.includes(layer.id), + ); const nextSelectedLayer = nextLayers .slice() .sort((left, right) => right.zIndex - left.zIndex)[0]; @@ -2996,6 +3934,17 @@ export function ImageCanvasEditorView() { setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, ); + setQuickEditPanel((currentPanel) => + currentPanel && targetIds.includes(currentPanel.sourceLayerId) + ? null + : currentPanel, + ); + setCharacterAnimationPanel((currentPanel) => + currentPanel && targetIds.includes(currentPanel.sourceLayerId) + ? null + : currentPanel, + ); + targetIds.forEach(removeCanvasGenerationDialogsByLayerId); }; const openGenerateDialog = () => { @@ -3003,8 +3952,7 @@ export function ImageCanvasEditorView() { const placeholderHeight = 420; const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; - captureCanvasHistory(); - setGenerateDialog({ + openCanvasGenerationDialog({ mode: 'generate', prompt: '', status: 'idle', @@ -3020,10 +3968,110 @@ export function ImageCanvasEditorView() { }); setActiveTool('generate'); selectSingleLayer(null); + setQuickEditPanel(null); + }; + + const openSpecDialog = (specType: SpecGenerationType) => { + const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; + const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; + openCanvasGenerationDialog({ + mode: 'spec', + prompt: '', + status: 'idle', + composerOpen: true, + specType, + specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] }, + placeholder: { + x: worldCenterX - SPEC_FRAME_DISPLAY_SIZE.width / 2, + y: worldCenterY - SPEC_FRAME_DISPLAY_SIZE.height / 2, + width: SPEC_FRAME_DISPLAY_SIZE.width, + height: SPEC_FRAME_DISPLAY_SIZE.height, + originalWidth: SPEC_FRAME_ORIGINAL_SIZE.width, + originalHeight: SPEC_FRAME_ORIGINAL_SIZE.height, + }, + }); + setIsSpecMenuOpen(false); + setActiveTool('generate'); + selectSingleLayer(null); + setQuickEditPanel(null); + }; + + const openCharacterAnimationPanel = (layer: CanvasLayer) => { + if (layer.assetKind !== 'character') { + return; + } + setImageContextMenu(null); + setQuickEditPanel(null); + setCharacterAnimationPanel({ + sourceLayerId: layer.id, + promptText: '', + resolution: '480p', + ratio: 'same', + frameCount: 32, + durationSeconds: 4, + status: 'idle', + }); + selectSingleLayer(layer.id); + }; + + const openCharacterGenerationDialog = () => { + const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; + const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; + setIsSpecMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + openCanvasGenerationDialog({ + mode: 'character', + prompt: '', + status: 'idle', + composerOpen: true, + characterSpecReference: null, + characterReferences: [], + placeholder: { + x: worldCenterX - CHARACTER_FRAME_DISPLAY_SIZE.width / 2, + y: worldCenterY - CHARACTER_FRAME_DISPLAY_SIZE.height / 2, + width: CHARACTER_FRAME_DISPLAY_SIZE.width, + height: CHARACTER_FRAME_DISPLAY_SIZE.height, + originalWidth: CHARACTER_FRAME_ORIGINAL_SIZE.width, + originalHeight: CHARACTER_FRAME_ORIGINAL_SIZE.height, + }, + }); + setActiveTool('character'); + selectSingleLayer(null); + setQuickEditPanel(null); + }; + + const openIconGenerationDialog = () => { + const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; + const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; + setIsSpecMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + setIsPickingIconSpecFromCanvas(false); + openCanvasGenerationDialog({ + mode: 'icon', + prompt: '', + status: 'idle', + composerOpen: true, + iconSpecReference: null, + iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS], + placeholder: { + x: worldCenterX - ICON_FRAME_DISPLAY_SIZE.width / 2, + y: worldCenterY - ICON_FRAME_DISPLAY_SIZE.height / 2, + width: ICON_FRAME_DISPLAY_SIZE.width, + height: ICON_FRAME_DISPLAY_SIZE.height, + originalWidth: ICON_FRAME_ORIGINAL_SIZE.width, + originalHeight: ICON_FRAME_ORIGINAL_SIZE.height, + }, + }); + setActiveTool('icon'); + selectSingleLayer(null); + setQuickEditPanel(null); + setCharacterAnimationPanel(null); }; const openEditDialog = (sourceLayer: CanvasLayer) => { setMetadataLayer(null); + setImageContextMenu(null); + setQuickEditPanel(null); setGenerateDialog({ mode: 'edit', prompt: sourceLayer.prompt @@ -3036,20 +4084,55 @@ export function ImageCanvasEditorView() { setActiveTool('generate'); }; + const openQuickEditPanel = (sourceLayer: CanvasLayer) => { + setImageContextMenu(null); + setMetadataLayer(null); + setGenerateDialog(null); + setCharacterAnimationPanel(null); + setQuickEditPanel({ + sourceLayerId: sourceLayer.id, + prompt: '', + size: formatImageSizeValue( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ), + model: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL, + status: 'idle', + }); + selectSingleLayer(sourceLayer.id); + setActiveTool('generate'); + }; + const addGeneratedResultLayer = ( generated: EditorImageGenerationResult, - options: { sourceLayer?: CanvasLayer; frame?: GenerateDialogState['placeholder'] } = {}, + options: { + sourceLayer?: CanvasLayer; + frame?: GenerateDialogState['placeholder']; + assetKind?: CanvasLayer['assetKind']; + title?: string; + dialogId?: string; + generationInputs?: CanvasGenerationInputs; + } = {}, ) => { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; const originalWidth = generated.width || 1024; const originalHeight = generated.height || 1024; - const longestSide = Math.max(originalWidth, originalHeight); - const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1; - const width = options.frame?.width ?? Math.round(originalWidth * sizeRatio); - const height = options.frame?.height ?? Math.round(originalHeight * sizeRatio); + const { width, height } = resolveLayerResolutionSize( + originalWidth, + originalHeight, + { width: 1024, height: 1024 }, + ); const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; + const frameX = + options.frame && options.frame.width > 0 + ? options.frame.x + options.frame.width / 2 - width / 2 + : undefined; + const frameY = + options.frame && options.frame.height > 0 + ? options.frame.y + options.frame.height / 2 - height / 2 + : undefined; const nextLayer: CanvasLayer = { id: options.sourceLayer ? `layer-edit-${generatedIndex}` @@ -3059,12 +4142,82 @@ export function ImageCanvasEditorView() { : `local-resource-generated-${generatedIndex}`, title: options.sourceLayer ? `${options.sourceLayer.title} 修改结果` - : `生成图片 ${generatedIndex}`, + : (options.title ?? `生成图片 ${generatedIndex}`), src: generated.imageSrc, x: options.sourceLayer ? options.sourceLayer.x + options.sourceLayer.width + 32 - : options.frame?.x ?? worldCenterX - width / 2, - y: options.sourceLayer ? options.sourceLayer.y : options.frame?.y ?? worldCenterY - height / 2, + : (frameX ?? worldCenterX - width / 2), + y: options.sourceLayer + ? options.sourceLayer.y + : (frameY ?? worldCenterY - height / 2), + width, + height, + originalWidth, + originalHeight, + zIndex: generatedIndex + 10, + sourceType: generated.sourceType, + assetKind: options.assetKind, + prompt: generated.prompt, + actualPrompt: generated.actualPrompt ?? generated.prompt, + model: generated.model, + provider: generated.provider, + taskId: generated.taskId, + objectKey: generated.objectKey, + assetObjectId: generated.assetObjectId, + sourceResourceId: options.sourceLayer?.resourceId, + generationInputs: options.generationInputs, + }; + + setLayers((currentLayers) => [...currentLayers, nextLayer]); + selectSingleLayer(nextLayer.id); + setActiveSidebarPanel('layers'); + if (options.sourceLayer) { + setGenerateDialog(null); + setActiveTool('select'); + } else if (options.dialogId) { + updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => + currentDialog.mode === 'character' || currentDialog.mode === 'icon' + ? null + : { + ...currentDialog, + status: 'idle', + composerOpen: true, + generatedLayerId: nextLayer.id, + placeholder: undefined, + errorMessage: undefined, + }, + ); + } + if (options.sourceLayer) { + fitLayers([options.sourceLayer, nextLayer]); + } + createProjectResourceForLayer(nextLayer); + }; + + const addQuickEditResultLayer = ( + generated: EditorImageGenerationResult, + sourceLayer: CanvasLayer, + generationInputs: CanvasGenerationInputs, + ) => { + layerCounterRef.current += 1; + const generatedIndex = layerCounterRef.current; + const originalWidth = generated.width || sourceLayer.originalWidth || 1024; + const originalHeight = generated.height || sourceLayer.originalHeight || 1024; + const { width, height } = resolveLayerResolutionSize( + originalWidth, + originalHeight, + { + width: sourceLayer.width, + height: sourceLayer.height, + }, + ); + const nextLayer: CanvasLayer = { + id: `layer-quick-edit-${generatedIndex}`, + resourceId: `local-resource-quick-edit-${generatedIndex}`, + title: `${sourceLayer.title} 快速编辑`, + src: generated.imageSrc, + x: sourceLayer.x + sourceLayer.width + 32, + y: sourceLayer.y, width, height, originalWidth, @@ -3076,97 +4229,363 @@ export function ImageCanvasEditorView() { model: generated.model, provider: generated.provider, taskId: generated.taskId, - sourceResourceId: options.sourceLayer?.resourceId, + objectKey: generated.objectKey, + assetObjectId: generated.assetObjectId, + sourceResourceId: sourceLayer.resourceId, + groupId: sourceLayer.groupId, + assetKind: sourceLayer.assetKind, + generationInputs, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); - if (options.sourceLayer) { - setGenerateDialog(null); - setActiveTool('select'); - } else { - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'generate' - ? { - ...currentDialog, - status: 'idle', - composerOpen: true, - generatedLayerId: nextLayer.id, - placeholder: undefined, - errorMessage: undefined, - } - : currentDialog, + setQuickEditPanel(null); + setActiveTool('select'); + fitLayers([sourceLayer, nextLayer]); + createProjectResourceForLayer(nextLayer); + }; + + const addIconSpritesheetResultLayers = ( + generated: EditorIconSpritesheetGenerationResult, + iconResults: EditorIconSpritesheetIconResult[], + generationInputs: CanvasGenerationInputs, + frame?: GenerateDialogState['placeholder'], + dialogId?: string, + ) => { + const startX = + frame?.x ?? + (canvasSize.width / 2 - viewport.x) / viewport.scale - + ICON_FRAME_DISPLAY_SIZE.width / 2; + const startY = + frame?.y ?? + (canvasSize.height / 2 - viewport.y) / viewport.scale - + ICON_FRAME_DISPLAY_SIZE.height / 2; + const spacing = 24; + const maxRowWidth = 560; + let cursorX = startX; + let cursorY = startY; + let rowHeight = 0; + const nextLayers: CanvasLayer[] = []; + + iconResults.forEach((icon) => { + const originalWidth = icon.width || 128; + const originalHeight = icon.height || 128; + const { width, height } = resolveLayerResolutionSize( + originalWidth, + originalHeight, + { width: 128, height: 128 }, ); - } - if (options.sourceLayer) { - fitLayers([options.sourceLayer, nextLayer]); - } - void createAssetFromGeneratedLayer(nextLayer); - void createProjectResourceForLayer(nextLayer, { - onFailed: () => { - setLayers((currentLayers) => - currentLayers.filter((currentLayer) => currentLayer.id !== nextLayer.id), - ); - setSelectedLayerIds((currentIds) => - currentIds.filter((layerId) => layerId !== nextLayer.id), - ); - setSelectedLayerId((currentId) => - currentId === nextLayer.id ? null : currentId, - ); - setGenerateDialog((currentDialog) => - currentDialog?.generatedLayerId === nextLayer.id - ? { - ...currentDialog, - generatedLayerId: undefined, - errorMessage: '图片已生成,但保存到画布失败,请稍后重试。', - } - : currentDialog, - ); - }, - saveLayout: true, + if (cursorX > startX && cursorX + width - startX > maxRowWidth) { + cursorX = startX; + cursorY += rowHeight + spacing; + rowHeight = 0; + } + + layerCounterRef.current += 1; + const generatedIndex = layerCounterRef.current; + nextLayers.push({ + id: `layer-icon-${generatedIndex}`, + resourceId: `local-resource-icon-${generatedIndex}`, + title: icon.name, + src: icon.imageSrc, + x: cursorX, + y: cursorY, + width, + height, + originalWidth, + originalHeight, + zIndex: generatedIndex + 10, + sourceType: 'generated', + prompt: generated.prompt, + actualPrompt: generated.actualPrompt ?? generated.prompt, + model: generated.model, + provider: generated.provider, + taskId: generated.taskId, + assetKind: 'icon', + generationInputs, + }); + + cursorX += width + spacing; + rowHeight = Math.max(rowHeight, height); }); + + if (!nextLayers.length) { + return; + } + setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + selectSingleLayer(nextLayers[0]?.id ?? null); + setActiveSidebarPanel('layers'); + if (dialogId) { + removeCanvasGenerationDialogById(dialogId); + } + setActiveTool('select'); + nextLayers.forEach((layer) => createProjectResourceForLayer(layer)); + }; + + const updateIconDescription = (index: number, value: string) => { + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'icon' + ? { + ...setIconGenerationIdle(currentDialog), + iconDescriptions: ( + currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS + ).map((description, descriptionIndex) => + descriptionIndex === index ? value : description, + ), + } + : currentDialog, + ); + }; + + const addIconDescription = () => { + setGenerateDialog((currentDialog) => { + if (currentDialog?.mode !== 'icon') { + return currentDialog; + } + const descriptions = + currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS; + if (descriptions.length >= ICON_DESCRIPTION_LIMIT) { + return currentDialog; + } + return { + ...setIconGenerationIdle(currentDialog), + iconDescriptions: [...descriptions, ''], + }; + }); + }; + + const submitIconSpritesheetGeneration = async ( + dialog: GenerateDialogState, + ) => { + if (dialog.mode !== 'icon') { + return; + } + const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; + const setSubmittingIconDialog = ( + nextDialog: CanvasGenerationDialogState, + ) => { + updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog); + }; + const iconDescriptions = ( + dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS + ) + .map((description) => description.trim()) + .filter(Boolean); + if (!dialog.iconSpecReference) { + if (canvasDialog) { + setSubmittingIconDialog({ + ...canvasDialog, + status: 'failed', + composerOpen: true, + errorMessage: '请选择图标素材规范', + }); + } + return; + } + if (!iconDescriptions.length) { + if (canvasDialog) { + setSubmittingIconDialog({ + ...canvasDialog, + status: 'failed', + composerOpen: true, + errorMessage: '请填写素材描述', + }); + } + return; + } + + if (!canvasDialog) { + return; + } + + setSubmittingIconDialog({ + ...canvasDialog, + iconDescriptions, + status: 'generating', + composerOpen: false, + errorMessage: undefined, + }); + + try { + const generated = await generateEditorIconSpritesheet({ + referenceImageSrc: dialog.iconSpecReference.src, + iconDescriptions, + }); + addIconSpritesheetResultLayers( + generated, + generated.iconImageSrcs, + buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference), + getGeneratingDialogPlaceholder(dialog), + canvasDialog.id, + ); + } catch (error) { + setSubmittingIconDialog({ + ...canvasDialog, + iconDescriptions, + status: 'failed', + composerOpen: true, + errorMessage: resolveImageGenerationErrorMessage(error), + }); + } + }; + + const submitQuickEdit = async () => { + if (!quickEditPanel || !quickEditSourceLayer) { + return; + } + + const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片'; + setQuickEditPanel({ + ...quickEditPanel, + prompt: normalizedPrompt, + status: 'generating', + errorMessage: undefined, + }); + + try { + const referenceImageSrc = + await resolveEditorImageReferenceDataUrl(quickEditSourceLayer.src); + const generated = await generateEditorImage({ + prompt: normalizedPrompt, + size: quickEditPanel.size, + kind: 'quick-edit', + model: quickEditPanel.model, + referenceImageSrcs: [referenceImageSrc], + }); + addQuickEditResultLayer( + generated, + quickEditSourceLayer, + buildEditGenerationInputs( + '快速编辑提示词', + normalizedPrompt, + quickEditSourceLayer, + ), + ); + } catch (error) { + setQuickEditPanel({ + ...quickEditPanel, + prompt: normalizedPrompt, + status: 'failed', + errorMessage: resolveImageGenerationErrorMessage(error), + }); + } }; const submitImageGeneration = async (dialog: GenerateDialogState) => { const normalizedPrompt = dialog.prompt.trim() || (dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片'); - captureCanvasHistory(); - setGenerateDialog({ - ...dialog, - prompt: normalizedPrompt, - status: 'generating', - composerOpen: true, - }); - - try { - if (dialog.mode === 'edit') { - const sourceLayer = layers.find((layer) => layer.id === dialog.sourceLayerId); - if (!sourceLayer) { - throw new Error('未找到要修改的图片'); - } - if (!sourceLayer.src.startsWith('data:image/')) { - throw new Error('当前图片缺少可提交的原图数据,请先使用生成图片结果进行修改'); - } - const generated = await editEditorImage({ - prompt: normalizedPrompt, - sourceImageSrc: sourceLayer.src, - }); - addGeneratedResultLayer(generated, { sourceLayer }); - } else { - const generated = await generateEditorImage({ prompt: normalizedPrompt }); - addGeneratedResultLayer(generated, { frame: dialog.placeholder }); - } - } catch (error) { + const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; + if (canvasDialog) { + updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({ + ...currentDialog, + prompt: normalizedPrompt, + status: 'generating', + composerOpen: false, + })); + } else { setGenerateDialog({ ...dialog, prompt: normalizedPrompt, - status: 'failed', - composerOpen: true, - errorMessage: resolveImageGenerationErrorMessage(error), + status: 'generating', + composerOpen: dialog.mode === 'edit', }); } + + try { + if (dialog.mode === 'edit') { + const sourceLayer = layers.find( + (layer) => layer.id === dialog.sourceLayerId, + ); + if (!sourceLayer) { + throw new Error('未找到要修改的图片'); + } + const referenceImageSrc = await resolveEditorImageReferenceDataUrl( + sourceLayer.src, + ); + const generated = await editEditorImage({ + prompt: normalizedPrompt, + sourceImageSrc: referenceImageSrc, + }); + addGeneratedResultLayer(generated, { + sourceLayer, + generationInputs: buildEditGenerationInputs( + '修改要求', + normalizedPrompt, + sourceLayer, + ), + }); + } else if (dialog.mode === 'spec') { + const specType = dialog.specType ?? 'custom'; + const specValues = + dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType]; + const specPrompt = buildSpecPrompt(specType, specValues); + const generated = await generateEditorImage({ + prompt: specPrompt, + size: SPEC_GENERATION_SIZE, + model: DEFAULT_IMAGE_MODEL, + kind: 'spec', + }); + addGeneratedResultLayer(generated, { + frame: getGeneratingDialogPlaceholder(dialog), + assetKind: specType === 'icon' ? 'icon-spec' : 'spec', + title: `${SPEC_TYPE_LABEL[specType]} ${layerCounterRef.current + 1}`, + dialogId: canvasDialog?.id, + generationInputs: buildSpecGenerationInputs(specType, specValues), + }); + } else if (dialog.mode === 'character') { + const referenceImageSrcs = [ + dialog.characterSpecReference?.src, + ...(dialog.characterReferences ?? []).map( + (reference) => reference.src, + ), + ].filter((src): src is string => Boolean(src)); + const generated = await generateEditorImage({ + prompt: normalizedPrompt, + kind: 'character', + ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), + }); + addGeneratedResultLayer(generated, { + frame: getGeneratingDialogPlaceholder(dialog), + assetKind: 'character', + title: `角色形象 ${layerCounterRef.current + 1}`, + dialogId: canvasDialog?.id, + generationInputs: buildCharacterGenerationInputs( + normalizedPrompt, + dialog.characterSpecReference, + dialog.characterReferences, + ), + }); + } else { + const generated = await generateEditorImage({ + prompt: normalizedPrompt, + }); + addGeneratedResultLayer(generated, { + frame: getGeneratingDialogPlaceholder(dialog), + dialogId: canvasDialog?.id, + generationInputs: buildImageGenerationInputs(normalizedPrompt), + }); + } + } catch (error) { + if (canvasDialog) { + updateCanvasGenerationDialogById(canvasDialog.id, () => ({ + ...canvasDialog, + prompt: normalizedPrompt, + status: 'failed', + composerOpen: true, + errorMessage: resolveImageGenerationErrorMessage(error), + })); + } else { + setGenerateDialog({ + ...dialog, + prompt: normalizedPrompt, + status: 'failed', + composerOpen: true, + errorMessage: resolveImageGenerationErrorMessage(error), + }); + } + } }; const handleWheel = (event: ReactWheelEvent) => { @@ -3206,69 +4625,6 @@ export function ImageCanvasEditorView() { }); }; - const handleViewportContextMenu = ( - event: ReactMouseEvent, - ) => { - event.preventDefault(); - const target = event.target as HTMLElement; - if (target.closest('.image-canvas-editor__layer')) { - return; - } - const canvasPoint = getCanvasPointFromClient(event.clientX, event.clientY); - const position = resolveContextMenuPosition( - event.clientX, - event.clientY, - 'blank', - ); - selectSingleLayer(null); - setContextMenu({ - kind: 'blank', - x: position.x, - y: position.y, - canvasPoint, - }); - }; - - const handleLayerContextMenu = ( - event: ReactMouseEvent, - layer: CanvasLayer, - ) => { - event.preventDefault(); - event.stopPropagation(); - const nextSelectedIds = selectedLayerIds.includes(layer.id) - ? selectedLayerIds - : [layer.id]; - setSelectedLayerId(layer.id); - setSelectedLayerIds(nextSelectedIds); - setGenerateDialog((currentDialog) => { - if (currentDialog?.mode !== 'generate') { - return currentDialog; - } - if (currentDialog.generatedLayerId === layer.id) { - return { - ...currentDialog, - composerOpen: true, - }; - } - return { - ...currentDialog, - composerOpen: false, - }; - }); - const position = resolveContextMenuPosition( - event.clientX, - event.clientY, - 'layer', - ); - setContextMenu({ - kind: 'layer', - x: position.x, - y: position.y, - layerId: layer.id, - canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY), - }); - }; - const startPan = (event: ReactPointerEvent) => { event.preventDefault(); const pointer = getPointerClient(event); @@ -3281,12 +4637,13 @@ export function ImageCanvasEditorView() { startClientY: pointer.y, startViewport: viewport, }; - dragHistoryCapturedRef.current = false; }; - const handleCanvasPointerDown = (event: ReactPointerEvent) => { + const handleCanvasPointerDown = ( + event: ReactPointerEvent, + ) => { const button = getPointerButton(event); - if (button === 1 || (button === 0 && effectiveTool === 'hand')) { + if (button !== 0 || effectiveTool === 'hand') { startPan(event); return; } @@ -3312,10 +4669,10 @@ export function ImageCanvasEditorView() { currentX: startX, currentY: startY, }); - selectSingleLayer(null); + clearCanvasFocus(); return; } - selectSingleLayer(null); + clearCanvasFocus(); }; const handleCanvasDragOver = (event: ReactDragEvent) => { @@ -3340,21 +4697,6 @@ export function ImageCanvasEditorView() { } }; - const getCanvasDropPoint = (event: ReactDragEvent) => { - const rect = canvasViewportRef.current?.getBoundingClientRect(); - const fallbackPoint = { - x: canvasSize.width / 2, - y: canvasSize.height / 2, - }; - if (!rect || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) { - return fallbackPoint; - } - return { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }; - }; - const handleCanvasDrop = (event: ReactDragEvent) => { const draggedAssetId = getDraggedAssetId(event.dataTransfer); if (draggedAssetId) { @@ -3364,6 +4706,7 @@ export function ImageCanvasEditorView() { } event.preventDefault(); setUploadDropTarget(null); + updateAssetMoveDropFolder(null); addAssetLayer(draggedAsset, getCanvasDropPoint(event)); return; } @@ -3373,6 +4716,7 @@ export function ImageCanvasEditorView() { } event.preventDefault(); setUploadDropTarget(null); + updateAssetMoveDropFolder(null); const canvasPoint = getCanvasDropPoint(event); const defaultFolder = assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0]; @@ -3396,9 +4740,26 @@ export function ImageCanvasEditorView() { if (button !== 0) { return; } + if ( + isPickingCharacterSpecFromCanvas && + generateDialog?.mode === 'character' + ) { + event.preventDefault(); + event.stopPropagation(); + pickCharacterSpecFromLayer(layer); + return; + } + if (isPickingIconSpecFromCanvas && generateDialog?.mode === 'icon') { + event.preventDefault(); + event.stopPropagation(); + pickIconSpecFromLayer(layer); + return; + } event.preventDefault(); event.stopPropagation(); + const pointer = getPointerClient(event); + canvasViewportRef.current?.setPointerCapture?.(event.pointerId); const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current; const nextSelectedIds = isMultiSelectGesture ? selectedLayerIds.includes(layer.id) @@ -3410,7 +4771,12 @@ export function ImageCanvasEditorView() { setSelectedLayerId(layer.id); setSelectedLayerIds(nextSelectedIds); setGenerateDialog((currentDialog) => { - if (currentDialog?.mode !== 'generate') { + if ( + currentDialog?.mode !== 'generate' && + currentDialog?.mode !== 'spec' && + currentDialog?.mode !== 'character' && + currentDialog?.mode !== 'icon' + ) { return currentDialog; } if (currentDialog.generatedLayerId === layer.id) { @@ -3424,18 +4790,8 @@ export function ImageCanvasEditorView() { composerOpen: false, }; }); - if (layer.locked) { - dragStateRef.current = null; - return; - } - const pointer = getPointerClient(event); - canvasViewportRef.current?.setPointerCapture?.(event.pointerId); const dragLayerIds = nextSelectedIds.includes(layer.id) - ? nextSelectedIds.filter((layerId) => - layers.some( - (currentLayer) => currentLayer.id === layerId && !currentLayer.locked, - ), - ) + ? nextSelectedIds : [layer.id]; const startLayers = layers .filter((currentLayer) => dragLayerIds.includes(currentLayer.id)) @@ -3456,13 +4812,13 @@ export function ImageCanvasEditorView() { startLayers, startScale: viewport.scale, }; - dragHistoryCapturedRef.current = false; }; const handleGenerationFramePointerDown = ( event: ReactPointerEvent, + dialog: CanvasGenerationDialogState, ) => { - if (!generateDialog?.placeholder) { + if (!dialog.placeholder) { return; } const button = getPointerButton(event); @@ -3471,7 +4827,7 @@ export function ImageCanvasEditorView() { startPan(event as unknown as ReactPointerEvent); return; } - if (button !== 0 || generateDialog.status === 'generating') { + if (button !== 0) { return; } @@ -3479,26 +4835,17 @@ export function ImageCanvasEditorView() { event.stopPropagation(); const pointer = getPointerClient(event); canvasViewportRef.current?.setPointerCapture?.(event.pointerId); - setSelectedLayerId(null); - setSelectedLayerIds([]); - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'generate' - ? { - ...currentDialog, - composerOpen: true, - } - : currentDialog, - ); + activateCanvasGenerationDialog(dialog); dragStateRef.current = { kind: 'generation-frame', + dialogId: dialog.id, pointerId: getPointerId(event), startClientX: pointer.x, startClientY: pointer.y, - startFrameX: generateDialog.placeholder.x, - startFrameY: generateDialog.placeholder.y, + startFrameX: dialog.placeholder.x, + startFrameY: dialog.placeholder.y, startScale: viewport.scale, }; - dragHistoryCapturedRef.current = false; }; const moveViewportFromMinimapPointer = (clientX: number, clientY: number) => { @@ -3515,9 +4862,11 @@ export function ImageCanvasEditorView() { const localX = clamp(clientX - rect.left, 0, MINIMAP_SIZE.width); const localY = clamp(clientY - rect.top, 0, MINIMAP_SIZE.height); const worldX = - minimapModel.bounds.minX + (localX - MINIMAP_PADDING) / minimapModel.scale; + minimapModel.bounds.minX + + (localX - MINIMAP_PADDING) / minimapModel.scale; const worldY = - minimapModel.bounds.minY + (localY - MINIMAP_PADDING) / minimapModel.scale; + minimapModel.bounds.minY + + (localY - MINIMAP_PADDING) / minimapModel.scale; setViewport((currentViewport) => ({ ...currentViewport, x: canvasSize.width / 2 - worldX * currentViewport.scale, @@ -3559,12 +4908,10 @@ export function ImageCanvasEditorView() { pointerId: getPointerId(event), startClientX: pointer.x, startClientY: pointer.y, - startViewport: { ...viewportRef.current }, + startViewport: { ...viewport }, minimapScale: minimapModel?.scale ?? 1, moved: false, }; - captureCanvasHistory(); - dragHistoryCapturedRef.current = true; }; const handlePointerMove = (event: ReactPointerEvent) => { @@ -3609,16 +4956,14 @@ export function ImageCanvasEditorView() { const pointerId = getPointerId(event); if ( !dragState || - (dragState.pointerId >= 0 && pointerId >= 0 && dragState.pointerId !== pointerId) + (dragState.pointerId >= 0 && + pointerId >= 0 && + dragState.pointerId !== pointerId) ) { return; } if (dragState.kind === 'pan') { - if (!dragHistoryCapturedRef.current) { - captureCanvasHistory(); - dragHistoryCapturedRef.current = true; - } const pointer = getPointerClient(event); setViewport({ ...dragState.startViewport, @@ -3629,15 +4974,13 @@ export function ImageCanvasEditorView() { } if (dragState.kind === 'generation-frame') { - if (!dragHistoryCapturedRef.current) { - captureCanvasHistory(); - dragHistoryCapturedRef.current = true; - } const pointer = getPointerClient(event); - const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; - const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'generate' && currentDialog.placeholder + const deltaX = + (pointer.x - dragState.startClientX) / dragState.startScale; + const deltaY = + (pointer.y - dragState.startClientY) / dragState.startScale; + updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) => + currentDialog.placeholder ? { ...currentDialog, placeholder: { @@ -3652,10 +4995,6 @@ export function ImageCanvasEditorView() { } if (dragState.kind === 'minimap') { - if (!dragHistoryCapturedRef.current) { - captureCanvasHistory(); - dragHistoryCapturedRef.current = true; - } const pointer = getPointerClient(event); const deltaX = pointer.x - dragState.startClientX; const deltaY = pointer.y - dragState.startClientY; @@ -3673,10 +5012,6 @@ export function ImageCanvasEditorView() { return; } const pointer = getPointerClient(event); - if (!dragHistoryCapturedRef.current) { - captureCanvasHistory(); - dragHistoryCapturedRef.current = true; - } const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; const snapped = resolveSnappedLayerPosition( @@ -3706,8 +5041,14 @@ export function ImageCanvasEditorView() { } return { ...layer, - x: startLayer.x + deltaX + (snapped.x - (dragState.startLayerX + deltaX)), - y: startLayer.y + deltaY + (snapped.y - (dragState.startLayerY + deltaY)), + x: + startLayer.x + + deltaX + + (snapped.x - (dragState.startLayerX + deltaX)), + y: + startLayer.y + + deltaY + + (snapped.y - (dragState.startLayerY + deltaY)), }; })() : layer, @@ -3729,14 +5070,15 @@ export function ImageCanvasEditorView() { const pointerId = getPointerId(event); if ( dragState && - (dragState.pointerId < 0 || pointerId < 0 || dragState.pointerId === pointerId) + (dragState.pointerId < 0 || + pointerId < 0 || + dragState.pointerId === pointerId) ) { if (dragState.kind === 'minimap' && !dragState.moved) { const pointer = getPointerClient(event); moveViewportFromMinimapPointer(pointer.x, pointer.y); } dragStateRef.current = null; - dragHistoryCapturedRef.current = false; setIsPanning(false); setSnapGuide(null); if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) { @@ -3750,6 +5092,7 @@ export function ImageCanvasEditorView() { setIsPanning(false); setSnapGuide(null); if (tool === 'upload') { + setUploadTarget('asset'); uploadInputRef.current?.click(); return; } @@ -3757,6 +5100,19 @@ export function ImageCanvasEditorView() { openGenerateDialog(); return; } + if (tool === 'spec') { + setIsSpecMenuOpen((open) => !open); + setActiveTool('spec'); + return; + } + if (tool === 'character') { + openCharacterGenerationDialog(); + return; + } + if (tool === 'icon') { + openIconGenerationDialog(); + return; + } setActiveTool(tool); }; @@ -3796,20 +5152,127 @@ export function ImageCanvasEditorView() { { label: '复制', icon: Copy }, ]; - const canvasTools: Array<{ id: CanvasTool; label: string; icon: typeof MousePointer2 }> = [ + const canvasTools: Array<{ + id: CanvasTool; + label: string; + icon: typeof MousePointer2; + }> = [ { id: 'select', label: '选择工具', icon: MousePointer2 }, { id: 'hand', label: '抓手工具', icon: Hand }, { id: 'upload', label: '上传工具', icon: ImagePlus }, { id: 'generate', label: '生成工具', icon: WandSparkles }, + { id: 'spec', label: '生成规范', icon: ClipboardList }, + { id: 'character', label: '生成角色形象', icon: Sparkles }, + { id: 'icon', label: '生成图标素材', icon: ImageIcon }, { id: 'text', label: '文字工具', icon: Type }, { id: 'shape', label: '形状标注工具', icon: Shapes }, { id: 'export', label: '导出工具', icon: Download }, ]; - const contextTargetLayers = contextMenu?.kind === 'layer' - ? layers.filter((layer) => getContextTargetLayerIds(contextMenu).includes(layer.id)) - : []; - const contextShouldShowLayer = contextTargetLayers.some((layer) => layer.hidden); - const contextShouldUnlockLayer = contextTargetLayers.some((layer) => layer.locked); + + const updateSpecFormValue = (key: keyof SpecFormValues, value: string) => { + setGenerateDialog((currentDialog) => { + if (currentDialog?.mode !== 'spec') { + return currentDialog; + } + const specType = currentDialog.specType ?? 'custom'; + return { + ...currentDialog, + specValues: { + ...DEFAULT_SPEC_FORM_VALUES[specType], + ...currentDialog.specValues, + [key]: value, + }, + status: + currentDialog.status === 'failed' ? 'idle' : currentDialog.status, + errorMessage: + currentDialog.status === 'failed' + ? undefined + : currentDialog.errorMessage, + }; + }); + }; + + const updateCharacterAnimationDuration = (frameCountValue: string) => { + const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find( + (item) => String(item.frameCount) === frameCountValue, + ); + if (!option) { + return; + } + setCharacterAnimationPanel((currentPanel) => + currentPanel + ? { + ...currentPanel, + frameCount: option.frameCount, + durationSeconds: option.durationSeconds, + status: + currentPanel.status === 'failed' ? 'idle' : currentPanel.status, + errorMessage: + currentPanel.status === 'failed' + ? undefined + : currentPanel.errorMessage, + } + : currentPanel, + ); + }; + + const submitCharacterAnimation = async () => { + if (!characterAnimationPanel || !characterAnimationSourceLayer) { + return; + } + const promptText = characterAnimationPanel.promptText.trim(); + const nextPanel = { + ...characterAnimationPanel, + promptText, + status: 'generating' as const, + errorMessage: undefined, + result: undefined, + }; + setCharacterAnimationPanel(nextPanel); + + try { + const result = await generateEditorCharacterAnimation({ + sourceLayerId: characterAnimationSourceLayer.id, + sourceImageSrc: resolveCharacterAnimationSourceImageSrc( + characterAnimationSourceLayer, + ), + sourceWidth: characterAnimationSourceLayer.originalWidth, + sourceHeight: characterAnimationSourceLayer.originalHeight, + promptText, + resolution: nextPanel.resolution, + ratio: nextPanel.ratio, + frameCount: nextPanel.frameCount, + durationSeconds: nextPanel.durationSeconds, + priceMudPoints: calculateCharacterAnimationPrice( + nextPanel.resolution, + nextPanel.durationSeconds, + ), + model: CHARACTER_ANIMATION_MODEL, + }); + setCharacterAnimationPanel((currentPanel) => + currentPanel + ? { + ...currentPanel, + status: 'completed', + result, + } + : currentPanel, + ); + } catch (error) { + setCharacterAnimationPanel((currentPanel) => + currentPanel + ? { + ...currentPanel, + status: 'failed', + errorMessage: + error instanceof Error && error.message.trim() + ? error.message + : '生成角色动画失败', + } + : currentPanel, + ); + } + }; return (
{ const files = event.currentTarget.files; if (files?.length) { - addUploadedFiles(files, { addToCanvas: activeTool === 'upload' }); + if (uploadTarget === 'character-spec') { + void addCharacterSpecReferenceFiles(files); + } else if (uploadTarget === 'character-reference') { + void addCharacterReferenceFiles(files); + } else if (uploadTarget === 'icon-spec') { + void addIconSpecReferenceFiles(files); + } else { + addUploadedFiles(files, { addToCanvas: activeTool === 'upload' }); + } } + setUploadTarget('asset'); event.currentTarget.value = ''; }} /> @@ -3837,26 +5309,30 @@ export function ImageCanvasEditorView() { ) : null} {activeSidebarPanel ? ( -