From 3a3cc89280e2c4fe29c6debdf0b809b9e908ea4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Tue, 16 Jun 2026 17:06:21 +0800 Subject: [PATCH] Image editor: hide raw Prompt, use Resolution Remove backend-assembled raw Prompt and copy action from image info; render a lightweight generationInputs snapshot (user panel inputs + reference thumbnails) stored on canvas layers and shown in the image info dialog. Unify canvas display and info to use originalWidth/originalHeight (Resolution) instead of saved Size and hydrate legacy layout width/height only as fallback. Add model/aspectRatio/imageSize options for character/icon generation (frontend state, tests, and client payloads). Increase Axum JSON body limit for character animation endpoint to 12MB for compatibility and prefer submitting persisted objectKey over large Data URLs. Update tests, docs, and related server/frontend code to reflect these behaviors and validations. --- .hermes/shared-memory/decision-log.md | 16 + .hermes/shared-memory/pitfalls.md | 8 + .../2026-06-16-editor-image-model-options.md | 146 +++++++ ...™¨ã€‘图片信æ¯ç”Ÿæˆè¾“入快照è½åœ°è®¡åˆ’-2026-06-16.md | 84 ++++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 8 +- ...辑器】画æ¿è§’色形象生æˆå…¥å£è®¾è®¡-2026-06-15.md | 3 +- server-rs/crates/api-server/src/app.rs | 47 +++ .../crates/api-server/src/editor_project.rs | 2 +- .../api-server/src/modules/play_flow.rs | 6 +- .../ImageCanvasEditorView.test.tsx | 345 +++++++++++++--- .../image-editor/ImageCanvasEditorView.tsx | 368 +++++++++++++++--- src/index.css | 47 ++- .../image-editor/editorProjectClient.test.ts | 84 ++++ .../image-editor/editorProjectClient.ts | 12 +- 14 files changed, 1041 insertions(+), 135 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-16-editor-image-model-options.md create mode 100644 docs/superpowers/plans/ã€ç¼–辑器】图片信æ¯ç”Ÿæˆè¾“入快照è½åœ°è®¡åˆ’-2026-06-16.md 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 3c8cdc7f..378e7742 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(); @@ -795,9 +823,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%' })); @@ -1117,6 +1143,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 () => { @@ -1163,6 +1201,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: '拖拽åŽçš„生æˆå›¾' }, }); @@ -1186,11 +1231,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 () => { @@ -1264,11 +1311,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', () => { @@ -1677,6 +1734,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(); @@ -1806,12 +1973,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(); @@ -2133,6 +2304,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', @@ -2338,6 +2529,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', @@ -2398,6 +2603,8 @@ describe('ImageCanvasEditorView', () => { originalHeight: 1024, zIndex: 2, sourceType: 'generated', + objectKey: + 'generated-character-drafts/editor/character-images/source/image.png', assetKind: 'character', }, { @@ -2514,7 +2721,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', @@ -2628,10 +2836,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', @@ -2640,11 +2848,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, }), ]), @@ -2940,8 +3148,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, @@ -2975,13 +3182,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'); @@ -2996,21 +3207,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: '修改图片' }); @@ -3037,9 +3245,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 06598364..735f3e15 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 = { @@ -383,8 +400,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, @@ -397,8 +414,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, @@ -544,6 +561,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])); } @@ -565,10 +594,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; @@ -620,6 +650,7 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { sourceResourceId: layer.sourceResourceId, groupId: layer.groupId, assetKind: layer.assetKind, + generationInputs: layer.generationInputs, }; } @@ -643,10 +674,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 @@ -661,6 +700,7 @@ function hydrateLayer( sourceResourceId: stringOrNull(snapshot.sourceResourceId), groupId: stringOrNull(snapshot.groupId), assetKind: canvasAssetKindOrNull(snapshot.assetKind), + generationInputs: generationInputsOrNull(snapshot.generationInputs), }; } @@ -717,6 +757,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' || @@ -821,6 +900,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 { @@ -831,6 +915,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', @@ -2408,10 +2596,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) => @@ -2672,19 +2861,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}` @@ -2698,10 +2896,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, @@ -2717,6 +2915,7 @@ export function ImageCanvasEditorView() { objectKey: generated.objectKey, assetObjectId: generated.assetObjectId, sourceResourceId: options.sourceLayer?.resourceId, + generationInputs: options.generationInputs, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); @@ -2748,9 +2947,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}`, @@ -2758,10 +2968,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, @@ -2774,6 +2984,7 @@ export function ImageCanvasEditorView() { sourceResourceId: sourceLayer.resourceId, groupId: sourceLayer.groupId, assetKind: sourceLayer.assetKind, + generationInputs, }; setLayers((currentLayers) => [...currentLayers, nextLayer]); @@ -2788,6 +2999,7 @@ export function ImageCanvasEditorView() { const addIconSpritesheetResultLayers = ( generated: EditorIconSpritesheetGenerationResult, iconResults: EditorIconSpritesheetIconResult[], + generationInputs: CanvasGenerationInputs, frame?: GenerateDialogState['placeholder'], dialogId?: string, ) => { @@ -2809,10 +3021,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; @@ -2840,6 +3053,7 @@ export function ImageCanvasEditorView() { provider: generated.provider, taskId: generated.taskId, assetKind: 'icon', + generationInputs, }); cursorX += width + spacing; @@ -2951,6 +3165,7 @@ export function ImageCanvasEditorView() { addIconSpritesheetResultLayers( generated, generated.iconImageSrcs, + buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference), getGeneratingDialogPlaceholder(dialog), canvasDialog.id, ); @@ -2988,7 +3203,15 @@ export function ImageCanvasEditorView() { model: quickEditPanel.model, referenceImageSrcs: [referenceImageSrc], }); - addQuickEditResultLayer(generated, quickEditSourceLayer); + addQuickEditResultLayer( + generated, + quickEditSourceLayer, + buildEditGenerationInputs( + '快速编辑æç¤ºè¯', + normalizedPrompt, + quickEditSourceLayer, + ), + ); } catch (error) { setQuickEditPanel({ ...quickEditPanel, @@ -3035,7 +3258,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 = @@ -3052,6 +3282,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 = [ @@ -3070,6 +3301,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({ @@ -3078,6 +3314,7 @@ export function ImageCanvasEditorView() { addGeneratedResultLayer(generated, { frame: getGeneratingDialogPlaceholder(dialog), dialogId: canvasDialog?.id, + generationInputs: buildImageGenerationInputs(normalizedPrompt), }); } } catch (error) { @@ -3688,7 +3925,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, @@ -4298,8 +4537,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 ? ( @@ -5846,24 +6085,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} ) : ( '-' @@ -5871,11 +6128,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 } : {}), }), '生æˆå›¾æ ‡ç´ æå¤±è´¥', {