diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b8e86e74..5ccebe0e 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -2278,3 +2278,19 @@ - 影响范围:`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 91a55f9c..0be1a973 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -39,6 +39,14 @@ - 验证:`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 - 现象:底部 `生成规范` 菜单、角色面板里的 `角色形象规范` 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。 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/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 562834d1..6f9dfa1d 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -12,11 +12,11 @@ - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 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 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图落在最新占位框位置,输入框继续跟随新生成图;点击所有图片生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 @@ -41,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 直接作为生图弹窗主文案。 @@ -85,7 +85,7 @@ - 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 -- 生成资源显示元数据按钮,元数据窗口展示来源、prompt、model、provider、task、尺寸和 OSS 引用。 +- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、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-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index e359270a..de46aec8 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -104,8 +104,9 @@ ### Prompt 与生成契约 - 前端提交到 `POST /api/editor/character-animations/generations`。 -- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长。 +- 请求必须带上角色图片来源、原始尺寸、动画描述、分辨率、画面比例、帧数和时长。角色图片已经持久化到 OSS 时,`sourceImageSrc` 必须优先传 `objectKey`;只有未持久化的本地临时图片才允许传 Data URL。 - 后端使用角色图片作为首帧和尾帧参考,模型固定映射到 seedance2.0 对应后端模型。 +- 后端路由兼容旧 Data URL 请求并单独放宽 JSON body limit 到 `12MB`,但该限额只作为兼容兜底,不作为新链路默认传大图的方式。 - 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后: ```text 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/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 0181a071..5e002c9a 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1251,7 +1251,7 @@ fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String fn build_editor_character_image_prompt(role_setting: &str) -> String { vec![ - "基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), + "严格基于图1的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), format!("角色设定:{}", role_setting.trim()), ] .join("\n") 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 283717dc..bc800e29 100644 --- a/server-rs/crates/api-server/src/modules/play_flow.rs +++ b/server-rs/crates/api-server/src/modules/play_flow.rs @@ -49,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 { @@ -455,7 +456,10 @@ fn play_flow_support_router(state: AppState) -> Router { ) .route( "/api/editor/character-animations/generations", - post(generate_editor_character_animation), + 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}", diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index e026921f..0779c9e5 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -66,29 +66,6 @@ function dispatchPointerEvent( fireEvent(target, event); } -function mockClipboard() { - const originalClipboard = Object.getOwnPropertyDescriptor( - navigator, - 'clipboard', - ); - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }); - - return { - writeText, - restore: () => { - if (originalClipboard) { - Object.defineProperty(navigator, 'clipboard', originalClipboard); - } else { - delete (navigator as unknown as { clipboard?: Clipboard }).clipboard; - } - }, - }; -} - describe('ImageCanvasEditorView', () => { beforeEach(() => { loadOrCreateRecentEditorProjectMock.mockResolvedValue({ @@ -557,14 +534,14 @@ describe('ImageCanvasEditorView', () => { expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy(); }); - it('shows image size on hover and placeholder toolbar after selecting a layer', () => { + it('shows image resolution on hover and placeholder toolbar after selecting a layer', () => { const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); 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'); @@ -612,10 +589,14 @@ describe('ImageCanvasEditorView', () => { const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' }); expect(within(infoPanel).getByText('图片类型')).toBeTruthy(); expect(within(infoPanel).getByText('上传图片')).toBeTruthy(); - expect(within(infoPanel).getByText('Prompt')).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).getByText('Size')).toBeTruthy(); - expect(within(infoPanel).getByText('420 x 420 px')).toBeTruthy(); + expect(within(infoPanel).queryByText('Size')).toBeNull(); expect(within(infoPanel).getByText('Resolution')).toBeTruthy(); expect(within(infoPanel).getByText('640 x 640 px')).toBeTruthy(); expect( @@ -624,6 +605,53 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull(); }); + 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(); @@ -837,9 +865,7 @@ describe('ImageCanvasEditorView', () => { ).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%' })); @@ -1206,6 +1232,18 @@ describe('ImageCanvasEditorView', () => { 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 () => { @@ -1252,6 +1290,13 @@ describe('ImageCanvasEditorView', () => { .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: '拖拽后的生成图' }, }); @@ -1275,11 +1320,13 @@ describe('ImageCanvasEditorView', () => { ); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); expect( - Number.parseFloat((generatedLayer as HTMLElement).style.left), - ).toBeGreaterThan(300); + 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), - ).toBeGreaterThan(180); + Number.parseFloat((generatedLayer as HTMLElement).style.top) + + Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2, + ).toBeCloseTo(draggedFrameCenterY, 1); }); it('keeps the generation placeholder draggable while the image is generating', async () => { @@ -1353,11 +1400,21 @@ describe('ImageCanvasEditorView', () => { .getByAltText(/画布图片:生成图片/) .closest('button')!; expect( - Number.parseFloat((generatedLayer as HTMLElement).style.left), - ).toBeGreaterThan(initialLeft); + 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), - ).toBeGreaterThan(initialTop); + 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', () => { @@ -1766,6 +1823,116 @@ describe('ImageCanvasEditorView', () => { }); }); + 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).getByLabelText('角色模型') as HTMLSelectElement) + .selectedOptions[0]?.textContent, + ).toBe('nanobanana2'); + expect( + ( + within(characterPanel).getByLabelText( + '角色尺寸比例', + ) as HTMLSelectElement + ).value, + ).toBe('1:1'); + expect( + ( + within(characterPanel).getByLabelText( + '角色大小尺寸', + ) as HTMLSelectElement + ).value, + ).toBe('1K'); + expect( + Array.from( + ( + within(characterPanel).getByLabelText( + '角色尺寸比例', + ) as HTMLSelectElement + ).options, + ).map((option) => option.value), + ).toContain('1:8'); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect( + (within(iconPanel).getByLabelText('图标模型') as HTMLSelectElement) + .selectedOptions[0]?.textContent, + ).toBe('nanobanana2'); + expect( + (within(iconPanel).getByLabelText('图标大小尺寸') as HTMLSelectElement) + .value, + ).toBe('1K'); + }); + + it('remembers the edited image model and submits character 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: 'gpt-image-2' }, + }); + expect( + Array.from( + ( + within(characterPanel).getByLabelText( + '角色尺寸比例', + ) as HTMLSelectElement + ).options, + ).map((option) => option.value), + ).not.toContain('1:8'); + fireEvent.change(within(characterPanel).getByLabelText('角色尺寸比例'), { + target: { value: '2:3' }, + }); + fireEvent.change(within(characterPanel).getByLabelText('角色大小尺寸'), { + target: { value: '1K' }, + }); + fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { + target: { value: '高个子游侠' }, + }); + fireEvent.click(within(characterPanel).getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'character', + model: 'gpt-image-2', + aspectRatio: '2:3', + imageSize: '1K', + }), + ); + }); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect( + (within(iconPanel).getByLabelText('图标模型') as HTMLSelectElement).value, + ).toBe('gpt-image-2'); + }); + it('keeps the bottom AI toolbar visible while generation panels are open', () => { render(); @@ -1895,12 +2062,16 @@ describe('ImageCanvasEditorView', () => { 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( - movedLeft, + expectedLayerLeft, 1, ); expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo( - movedTop, + expectedLayerTop, 1, ); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); @@ -2222,6 +2393,26 @@ describe('ImageCanvasEditorView', () => { 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', @@ -2427,6 +2618,20 @@ describe('ImageCanvasEditorView', () => { }); 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', @@ -2487,6 +2692,8 @@ describe('ImageCanvasEditorView', () => { originalHeight: 1024, zIndex: 2, sourceType: 'generated', + objectKey: + 'generated-character-drafts/editor/character-images/source/image.png', assetKind: 'character', }, { @@ -2603,7 +2810,8 @@ describe('ImageCanvasEditorView', () => { expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( expect.objectContaining({ sourceLayerId: 'layer-character', - sourceImageSrc: 'data:image/png;base64,character', + sourceImageSrc: + 'generated-character-drafts/editor/character-images/source/image.png', sourceWidth: 1024, sourceHeight: 1024, resolution: '720p', @@ -2717,10 +2925,10 @@ describe('ImageCanvasEditorView', () => { const generatedLayer = screen .getByAltText('画布图片:魔法森林 快速编辑') .closest('button') as HTMLElement; - expect(Number.parseFloat(generatedLayer.style.left)).toBe(472); + expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688); expect(Number.parseFloat(generatedLayer.style.top)).toBe(140); - expect(Number.parseFloat(generatedLayer.style.width)).toBe(320); - expect(Number.parseFloat(generatedLayer.style.height)).toBe(240); + 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', @@ -2729,11 +2937,11 @@ describe('ImageCanvasEditorView', () => { expect.objectContaining({ title: '魔法森林 快速编辑', assetKind: 'spec', - width: 320, - height: 240, + width: 1536, + height: 1024, originalWidth: 1536, originalHeight: 1024, - x: 472, + x: 1688, y: 140, }), ]), @@ -3029,8 +3237,7 @@ describe('ImageCanvasEditorView', () => { ).toBeNull(); }); - it('opens generated image info from the corner button, copies Prompt and creates a real right-side edit result', async () => { - const clipboard = mockClipboard(); + 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, @@ -3064,13 +3271,17 @@ 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: /查看生成图片 .*图片信息/, })[0]; if (!metadataCornerButton) { - clipboard.restore(); throw new Error('metadata corner button should exist'); } expect(metadataCornerButton.className).toContain('bg-black/55'); @@ -3085,21 +3296,18 @@ describe('ImageCanvasEditorView', () => { expect(metadataDialog).toBeTruthy(); expect(within(metadataDialog).getByText('图片类型')).toBeTruthy(); expect(within(metadataDialog).getByText('生成图片')).toBeTruthy(); - expect(within(metadataDialog).getByText('Prompt')).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).getByText('Size')).toBeTruthy(); - expect(within(metadataDialog).getByText('420 x 420 px')).toBeTruthy(); + expect(within(metadataDialog).queryByText('Size')).toBeNull(); expect(within(metadataDialog).getByText('Resolution')).toBeTruthy(); expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy(); - fireEvent.click( - within(metadataDialog).getByRole('button', { name: '复制Prompt' }), - ); - await waitFor(() => { - expect(clipboard.writeText).toHaveBeenCalledWith('一张可修改的生成图'); - }); - clipboard.restore(); fireEvent.click(screen.getByRole('button', { name: '修改图片' })); const editDialog = screen.getByRole('dialog', { name: '修改图片' }); @@ -3126,9 +3334,22 @@ describe('ImageCanvasEditorView', () => { expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull(); }); expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy(); + fireEvent.click( + screen.getAllByRole('button', { + name: /查看生成图片 .* 修改结果图片信息/u, + })[0]!, + ); + const editedMetadataDialog = screen.getByRole('dialog', { + name: /生成图片 .* 修改结果图片信息/u, + }); + expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); + expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy(); + expect(within(editedMetadataDialog).getByText('把画面改成黄昏光线')).toBeTruthy(); + expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy(); expect( - screen.getByRole('button', { name: '当前缩放比例 100%' }), + within(editedMetadataDialog).getByText(/^生成图片 \d+$/u), ).toBeTruthy(); + expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy(); }); it('hides the edit image panel after generation starts while keeping the source preview visible', async () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 124b9529..6531a485 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -111,6 +111,22 @@ type EditorAsset = { assetObjectId?: 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; @@ -134,6 +150,7 @@ type CanvasLayer = { sourceResourceId?: string | null; groupId?: string | null; assetKind?: 'spec' | 'character' | 'icon' | 'icon-spec' | null; + generationInputs?: CanvasGenerationInputs | null; }; type CanvasViewport = { @@ -388,8 +405,8 @@ const INITIAL_LAYERS: CanvasLayer[] = [ src: '/creation-type-references/puzzle.webp', x: 470, y: 300, - width: 420, - height: 420, + width: 640, + height: 640, originalWidth: 640, originalHeight: 640, zIndex: 1, @@ -402,8 +419,8 @@ const INITIAL_LAYERS: CanvasLayer[] = [ 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, @@ -550,6 +567,18 @@ function formatImageSizeValue(width: number, height: number) { return `${safeWidth}x${safeHeight}`; } +function resolveLayerResolutionSize( + originalWidth: number, + originalHeight: number, + fallback: { width: number; height: number }, +) { + // 中文注释:画布不再维护独立展示 Size,图片显示尺寸统一跟随图片原始 Resolution。 + return { + 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])); } @@ -571,10 +600,11 @@ 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 { width, height } = resolveLayerResolutionSize( + asset.width, + asset.height, + { width: 360, height: 360 }, + ); const worldCenterX = (screenCenter.x - viewport.x) / viewport.scale; const worldCenterY = (screenCenter.y - viewport.y) / viewport.scale; const offset = index * 34; @@ -625,6 +655,7 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { sourceResourceId: layer.sourceResourceId, groupId: layer.groupId, assetKind: layer.assetKind, + generationInputs: layer.generationInputs, }; } @@ -650,10 +681,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 @@ -668,6 +707,7 @@ function hydrateLayer( sourceResourceId: stringOrNull(snapshot.sourceResourceId), groupId: stringOrNull(snapshot.groupId), assetKind: canvasAssetKindOrNull(snapshot.assetKind), + generationInputs: generationInputsOrNull(snapshot.generationInputs), }; } @@ -724,6 +764,45 @@ function stringOrNull(value: unknown) { return typeof value === 'string' && value.trim() ? value : null; } +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' || @@ -828,6 +907,11 @@ function calculateCharacterAnimationPrice( 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 { @@ -838,6 +922,110 @@ function createCanvasLayerReference( }; } +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', @@ -2421,10 +2609,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) => @@ -2685,19 +2874,28 @@ export function ImageCanvasEditorView() { 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}` @@ -2711,10 +2909,10 @@ export function ImageCanvasEditorView() { src: generated.imageSrc, x: options.sourceLayer ? options.sourceLayer.x + options.sourceLayer.width + 32 - : (options.frame?.x ?? worldCenterX - width / 2), + : (frameX ?? worldCenterX - width / 2), y: options.sourceLayer ? options.sourceLayer.y - : (options.frame?.y ?? worldCenterY - height / 2), + : (frameY ?? worldCenterY - height / 2), width, height, originalWidth, @@ -2730,6 +2928,7 @@ export function ImageCanvasEditorView() { objectKey: generated.objectKey, assetObjectId: generated.assetObjectId, sourceResourceId: options.sourceLayer?.resourceId, + generationInputs: options.generationInputs, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); @@ -2761,9 +2960,20 @@ export function ImageCanvasEditorView() { 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}`, @@ -2771,10 +2981,10 @@ export function ImageCanvasEditorView() { src: generated.imageSrc, x: sourceLayer.x + sourceLayer.width + 32, y: sourceLayer.y, - width: sourceLayer.width, - height: sourceLayer.height, - originalWidth: sourceLayer.originalWidth, - originalHeight: sourceLayer.originalHeight, + width, + height, + originalWidth, + originalHeight, zIndex: generatedIndex + 10, sourceType: generated.sourceType, prompt: generated.prompt, @@ -2787,6 +2997,7 @@ export function ImageCanvasEditorView() { sourceResourceId: sourceLayer.resourceId, groupId: sourceLayer.groupId, assetKind: sourceLayer.assetKind, + generationInputs, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); @@ -2801,6 +3012,7 @@ export function ImageCanvasEditorView() { const addIconSpritesheetResultLayers = ( generated: EditorIconSpritesheetGenerationResult, iconResults: EditorIconSpritesheetIconResult[], + generationInputs: CanvasGenerationInputs, frame?: GenerateDialogState['placeholder'], dialogId?: string, ) => { @@ -2822,10 +3034,11 @@ export function ImageCanvasEditorView() { iconResults.forEach((icon) => { const originalWidth = icon.width || 128; const originalHeight = icon.height || 128; - const longestSide = Math.max(originalWidth, originalHeight); - const sizeRatio = longestSide > 0 ? Math.min(1, 128 / longestSide) : 1; - const width = Math.round(originalWidth * sizeRatio); - const height = Math.round(originalHeight * sizeRatio); + const { width, height } = resolveLayerResolutionSize( + originalWidth, + originalHeight, + { width: 128, height: 128 }, + ); if (cursorX > startX && cursorX + width - startX > maxRowWidth) { cursorX = startX; cursorY += rowHeight + spacing; @@ -2853,6 +3066,7 @@ export function ImageCanvasEditorView() { provider: generated.provider, taskId: generated.taskId, assetKind: 'icon', + generationInputs, }); cursorX += width + spacing; @@ -2964,6 +3178,7 @@ export function ImageCanvasEditorView() { addIconSpritesheetResultLayers( generated, generated.iconImageSrcs, + buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference), getGeneratingDialogPlaceholder(dialog), canvasDialog.id, ); @@ -3001,7 +3216,15 @@ export function ImageCanvasEditorView() { model: quickEditPanel.model, referenceImageSrcs: [referenceImageSrc], }); - addQuickEditResultLayer(generated, quickEditSourceLayer); + addQuickEditResultLayer( + generated, + quickEditSourceLayer, + buildEditGenerationInputs( + '快速编辑提示词', + normalizedPrompt, + quickEditSourceLayer, + ), + ); } catch (error) { setQuickEditPanel({ ...quickEditPanel, @@ -3048,7 +3271,14 @@ export function ImageCanvasEditorView() { prompt: normalizedPrompt, sourceImageSrc: referenceImageSrc, }); - addGeneratedResultLayer(generated, { sourceLayer }); + addGeneratedResultLayer(generated, { + sourceLayer, + generationInputs: buildEditGenerationInputs( + '修改要求', + normalizedPrompt, + sourceLayer, + ), + }); } else if (dialog.mode === 'spec') { const specType = dialog.specType ?? 'custom'; const specValues = @@ -3065,6 +3295,7 @@ export function ImageCanvasEditorView() { 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 = [ @@ -3083,6 +3314,11 @@ export function ImageCanvasEditorView() { assetKind: 'character', title: `角色形象 ${layerCounterRef.current + 1}`, dialogId: canvasDialog?.id, + generationInputs: buildCharacterGenerationInputs( + normalizedPrompt, + dialog.characterSpecReference, + dialog.characterReferences, + ), }); } else { const generated = await generateEditorImage({ @@ -3091,6 +3327,7 @@ export function ImageCanvasEditorView() { addGeneratedResultLayer(generated, { frame: getGeneratingDialogPlaceholder(dialog), dialogId: canvasDialog?.id, + generationInputs: buildImageGenerationInputs(normalizedPrompt), }); } } catch (error) { @@ -3738,7 +3975,9 @@ export function ImageCanvasEditorView() { try { const result = await generateEditorCharacterAnimation({ sourceLayerId: characterAnimationSourceLayer.id, - sourceImageSrc: characterAnimationSourceLayer.src, + sourceImageSrc: resolveCharacterAnimationSourceImageSrc( + characterAnimationSourceLayer, + ), sourceWidth: characterAnimationSourceLayer.originalWidth, sourceHeight: characterAnimationSourceLayer.originalHeight, promptText, @@ -4348,8 +4587,8 @@ export function ImageCanvasEditorView() { size="xs" className="image-canvas-editor__size-badge" > - {Math.round(layer.width)} x {Math.round(layer.height)}{' '} - px + {Math.round(layer.originalWidth)} x{' '} + {Math.round(layer.originalHeight)} px ) : null} {layerGeneratingLabel ? ( @@ -5896,24 +6135,42 @@ export function ImageCanvasEditorView() {
图片类型
{formatLayerImageType(metadataLayer)}
-
Prompt
-
- {metadataLayer.prompt ? ( +
生成输入
+
+ {metadataLayer.generationInputs?.fields.length || + metadataLayer.generationInputs?.references.length ? ( <> - {metadataLayer.prompt} - { - void navigator.clipboard?.writeText( - metadataLayer.prompt ?? '', - ); - }} - > - 复制Prompt - + {metadataLayer.generationInputs.fields.map((field) => ( +
+ + {field.title} + + {field.value} +
+ ))} + {metadataLayer.generationInputs.references.length ? ( +
+ {metadataLayer.generationInputs.references.map( + (reference) => ( +
+ + + + {reference.title} + + {reference.label} + +
+ ), + )} +
+ ) : null} ) : ( '-' @@ -5921,11 +6178,6 @@ export function ImageCanvasEditorView() {
Model
{metadataLayer.model ?? '-'}
-
Size
-
- {Math.round(metadataLayer.width)} x{' '} - {Math.round(metadataLayer.height)} px -
Resolution
{metadataLayer.originalWidth} x {metadataLayer.originalHeight} px diff --git a/src/index.css b/src/index.css index 132daf9f..16b7781e 100644 --- a/src/index.css +++ b/src/index.css @@ -5205,18 +5205,53 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { font-weight: 760; } -.image-canvas-editor__metadata-prompt { +.image-canvas-editor__metadata-inputs { display: flex; flex-direction: column; align-items: flex-start; gap: 0.42rem; } -.image-canvas-editor__metadata-copy { - cursor: pointer; - border: 1px solid rgba(148, 163, 184, 0.28); - background: #ffffff; - color: #334155; +.image-canvas-editor__metadata-input-field { + display: grid; + gap: 0.16rem; +} + +.image-canvas-editor__metadata-input-title { + color: #64748b; + font-size: 0.7rem; + font-weight: 820; +} + +.image-canvas-editor__metadata-reference-list { + display: grid; + width: 100%; + gap: 0.42rem; +} + +.image-canvas-editor__metadata-reference-card { + display: grid; + grid-template-columns: 2.4rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.26); + border-radius: 0.65rem; + background: rgba(248, 250, 252, 0.92); + padding: 0.38rem; +} + +.image-canvas-editor__metadata-reference-card img { + width: 2.4rem; + height: 2.4rem; + border-radius: 0.48rem; + object-fit: cover; +} + +.image-canvas-editor__metadata-reference-copy { + display: grid; + min-width: 0; + gap: 0.12rem; } @media (max-width: 760px) { diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index c7c07d5d..d3b5eb00 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -567,6 +567,48 @@ describe('editorProjectClient', () => { ); }); + it('passes image model options to editor image generation', async () => { + requestJsonMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,character', + width: 1024, + height: 1536, + sourceType: 'generated', + prompt: '角色设定', + actualPrompt: '角色设定', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'vector-character-1', + }); + + await generateEditorImage({ + prompt: '角色设定', + kind: 'character', + model: 'gpt-image-2', + aspectRatio: '2:3', + imageSize: '1K', + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/images/generations', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: '角色设定', + kind: 'character', + model: 'gpt-image-2', + aspectRatio: '2:3', + imageSize: '1K', + }), + }), + '生成图片失败', + expect.objectContaining({ + timeoutMs: 1_200_000, + retry: { maxRetries: 0 }, + }), + ); + }); + it('generates icon spritesheets through the dedicated backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', @@ -614,6 +656,48 @@ describe('editorProjectClient', () => { ); }); + it('passes image model options to icon spritesheet generation', async () => { + requestJsonMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,sheet', + spritesheetWidth: 1024, + spritesheetHeight: 1024, + iconImageSrcs: [], + prompt: '图标素材 prompt', + actualPrompt: '图标素材 prompt', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'icon-spritesheet-task-2', + }); + + await generateEditorIconSpritesheet({ + referenceImageSrc: 'data:image/png;base64,spec', + iconDescriptions: ['返回按钮'], + model: 'gpt-image-2', + aspectRatio: '1:1', + imageSize: '2K', + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/icon-spritesheets/generations', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + referenceImageSrc: 'data:image/png;base64,spec', + iconDescriptions: ['返回按钮'], + model: 'gpt-image-2', + aspectRatio: '1:1', + imageSize: '2K', + }), + }), + '生成图标素材失败', + expect.objectContaining({ + timeoutMs: 1_200_000, + retry: { maxRetries: 0 }, + }), + ); + }); + it('passes spec generation size and kind to the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,spec', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 884f3c10..3a463435 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -8,7 +8,7 @@ const EDITOR_ICON_SPRITESHEET_GENERATION_API = '/api/editor/icon-spritesheets/generations'; const EDITOR_CHARACTER_ANIMATION_GENERATION_API = '/api/editor/character-animations/generations'; -const EDITOR_ICON_SPRITESHEET_MODEL = 'gemini-3.1-flash-image-preview'; +const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; const DEFAULT_PROJECT_TITLE = '未命名画布'; const EDITOR_PROJECT_REQUEST_OPTIONS = { clearAuthOnUnauthorized: false, @@ -89,6 +89,8 @@ export type EditorImageGenerationInput = { size?: string; kind?: 'spec' | 'character' | 'quick-edit'; model?: string; + aspectRatio?: string; + imageSize?: string; referenceImageSrcs?: string[]; }; @@ -96,6 +98,8 @@ export type EditorIconSpritesheetGenerationInput = { referenceImageSrc: string; iconDescriptions: string[]; model?: string; + aspectRatio?: string; + imageSize?: string; }; export type EditorImageEditInput = { @@ -477,6 +481,8 @@ export async function generateEditorImage(input: EditorImageGenerationInput) { ...(input.size ? { size: input.size } : {}), ...(input.kind ? { kind: input.kind } : {}), ...(input.model ? { model: input.model } : {}), + ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), + ...(input.imageSize ? { imageSize: input.imageSize } : {}), ...(input.referenceImageSrcs?.length ? { referenceImageSrcs: input.referenceImageSrcs } : {}), @@ -500,7 +506,9 @@ export async function generateEditorIconSpritesheet( jsonRequest('POST', { referenceImageSrc: input.referenceImageSrc, iconDescriptions: input.iconDescriptions, - model: input.model?.trim() || EDITOR_ICON_SPRITESHEET_MODEL, + model: input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2, + ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), + ...(input.imageSize ? { imageSize: input.imageSize } : {}), }), '生成图标素材失败', {