From 132bf7db358a77eee44a092b421e041720a7134f Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 12 Aug 2026 18:14:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=A4=9A=E6=96=B9=E5=90=91?= =?UTF-8?q?=E5=9B=BE=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...‰²è§„èŒƒåˆ°å¤šè§†è§’å›¾é›†æ‹†åˆ†ä¸ŽåŠ¨ç”»ç”Ÿæˆæµç¨‹-2026-08-12.md | 88 ++++++++++++++ .../api-server/src/editor_agent/tool.rs | 3 + .../crates/api-server/src/editor_project.rs | 113 +++++++++++++++++ .../api-server/src/editor_project_icon.rs | 51 +++++++- ...asCharacterGenerationComposerView.test.tsx | 64 ++++++++++ ...eCanvasCharacterGenerationComposerView.tsx | 114 +++++++++++++++++- .../image-editor/ImageCanvasEditorTypes.ts | 6 + .../ImageCanvasGenerationDialogModel.test.ts | 35 ++++++ .../ImageCanvasGenerationDialogModel.ts | 37 ++++++ .../ImageCanvasGenerationInputsModel.ts | 2 + .../ImageCanvasGenerationModel.ts | 52 ++++++++ ...ageCanvasGenerationSubmissionModel.test.ts | 81 +++++++++++++ .../ImageCanvasGenerationSubmissionModel.ts | 62 +++++++--- .../image-editor/ImageCanvasReferenceSlot.tsx | 11 +- src/index.css | 22 ++++ .../image-editor/editorProjectClient.ts | 12 +- 16 files changed, 727 insertions(+), 26 deletions(-) create mode 100644 docs/technical/ã€æŠ€æœ¯æ–¹æ¡ˆã€‘è§’è‰²è§„èŒƒåˆ°å¤šè§†è§’å›¾é›†æ‹†åˆ†ä¸ŽåŠ¨ç”»ç”Ÿæˆæµç¨‹-2026-08-12.md diff --git a/docs/technical/ã€æŠ€æœ¯æ–¹æ¡ˆã€‘è§’è‰²è§„èŒƒåˆ°å¤šè§†è§’å›¾é›†æ‹†åˆ†ä¸ŽåŠ¨ç”»ç”Ÿæˆæµç¨‹-2026-08-12.md b/docs/technical/ã€æŠ€æœ¯æ–¹æ¡ˆã€‘è§’è‰²è§„èŒƒåˆ°å¤šè§†è§’å›¾é›†æ‹†åˆ†ä¸ŽåŠ¨ç”»ç”Ÿæˆæµç¨‹-2026-08-12.md new file mode 100644 index 000000000..04114540d --- /dev/null +++ b/docs/technical/ã€æŠ€æœ¯æ–¹æ¡ˆã€‘è§’è‰²è§„èŒƒåˆ°å¤šè§†è§’å›¾é›†æ‹†åˆ†ä¸ŽåŠ¨ç”»ç”Ÿæˆæµç¨‹-2026-08-12.md @@ -0,0 +1,88 @@ +# ã€æŠ€æœ¯æ–¹æ¡ˆã€‘è§’è‰²è®¾å®š → 多方å‘角色图 → 拆分 → åŠ¨ç”»ç”Ÿæˆ + +- 日期:2026-08-12 +- 状æ€ï¼šè®¾è®¡ç¨¿ v3(简化:唯一新增能力是"从角色设定生æˆå¤šå‘角色图",拆分与动画全走现有能力) +- 实现状æ€ï¼š**核心已实现**(多å‘图生æˆï¼šåŽç«¯ `character-sheet` kind + å‰ç«¯è§’è‰²é¢æ¿å¼€å…³ï¼Œ2026-08-12) + - 拆分集æˆï¼šè§’色多方å‘图(action=`character-sheet.generate`ï¼‰ç»æ—¢æœ‰ã€Œæ‹†åˆ†å›¾é›†ã€åˆ‡å‡ºçš„å•视角切片按 `character` è½åº“,å¯ç›´æŽ¥ä½œä¸ºåŠ¨ç”»å‚考(`editor_spritesheet_slice_record_asset_kind`,`editor_project_icon.rs`) +- å…³è”:`ã€æŠ€æœ¯æ–¹æ¡ˆã€‘ArtAgent多模æ€Spineåºåˆ—帧动画生æˆ-2026-08-10.md` + +## 1. 背景 + +- Spine åºåˆ—帧动画(`character-animation`)是"图生视频 → 抽帧",图生视频以首帧为准;首帧里角色越多,输出多角色概率越大。 +- 现状角色形象以多视角规范图为å‚考,å¯èƒ½äº§å‡º"一个角色多个视角"的多角色图,直接当动画首帧会多角色。 +- 结论:需è¦ä»Žè§’色设定**能生æˆä¸€å¼ "多方å‘角色图"**(åŒä¸€è§’色ã€å¤šæ–¹å‘ã€å½¼æ­¤åˆ†ç¦»ï¼‰ï¼Œæ‹†æˆå•å¼ åŽåˆ†åˆ«åšåŠ¨ç”»ã€‚ + +## 2. 范围 + +**唯一新增能力**:从角色设定(角色规范图 / 已有å•视角角色图)生æˆä¸€å¼ **多方å‘角色图**(四视图默认)。 + +**其余全部å¤ç”¨çŽ°æœ‰èƒ½åŠ›ï¼Œä¸åšé¢å¤–ä¿éšœï¼š** +- 图集类型:å¤ç”¨çŽ°æœ‰ `icon-spritesheet`(「图集ã€ï¼‰ï¼Œä¸æ–°å¢žèµ„产类型。 +- 拆分:å¤ç”¨çŽ°æœ‰ã€Œæ‹†åˆ†å›¾é›†ã€ï¼ˆè¿žé€šå—拆分,`split_editor_icon_spritesheet`),**手动触å‘**,ä¸è‡ªåŠ¨æ‹†åˆ†ã€‚ +- æ–¹å‘/æ•°é‡ï¼š**ä¸ä¿è¯**。生æˆé”™äº†ç”¨æˆ·é‡æ–°æŠ½å¡ï¼›æ–¹å‘标签用户自己看。 +- 动画:对拆分出的å•è§†è§’å›¾ï¼ŒæŒ‰çŽ°æœ‰åŠ¨ç”»é¢æ¿é€ä¸ªç”Ÿæˆã€‚ + +## 3. æµç¨‹ + +``` +角色设定(规范图 / 已有角色图) + │ å‚考 + â–¼ +ã€æ–°å¢žã€‘生æˆå¤šæ–¹å‘角色图 → 产出 icon-spritesheet 图集 + │ + â–¼ +ã€çŽ°æœ‰ã€‘ç”¨æˆ·æ‰‹åŠ¨ã€Œæ‹†åˆ†å›¾é›†ã€ï¼ˆè¿žé€šå—)→ N å¼ å•视角图 + │ + â–¼ +ã€çŽ°æœ‰ã€‘é€‰å•视角图 → 图生视频 → 抽帧 → Spineåºåˆ—帧 +``` + +## 4. 新增能力:生æˆå¤šæ–¹å‘角色图 + +- å…¥å£ï¼šåœ¨ã€Œç”Ÿæˆè§’色形象ã€é¢æ¿åŠ ä¸€ä¸ªæŒ‰é’®ã€Œç”Ÿæˆå¤šæ–¹å‘角色图ã€ï¼›æˆ–å¤ç”¨ã€Œç”Ÿæˆå›¾æ ‡ç´ æã€é¢æ¿åˆ‡åˆ°è§’è‰²å¤šå‘æ¨¡å¼ã€‚建议å‰è€…。 +- 链路:å¤ç”¨å›¾é›†ç”Ÿæˆé“¾è·¯ï¼ˆ`generate_editor_icon_spritesheet_for_owner`),模型 `nanobanana2`。 +- 输出:一张 `icon-spritesheet` 图集,四个方å‘(å‰/åŽ/å·¦/å³ï¼‰ï¼ŒåŒä¸€è§’è‰²ã€æ–¹å‘之间纯色底间隔ã€å½¼æ­¤åˆ†ç¦»ã€‚ +- æç¤ºè¯æ˜¯è´¨é‡å…³é”®ï¼ˆè‰æ¡ˆï¼‰ï¼š + +``` +æ ¹æ®è§’è‰²è®¾å®šç”Ÿæˆæ¸¸æˆè§’色的多方å‘视图图集。 +ç”»é¢ä¸­åªæœ‰åŒä¸€ä¸ªè§’è‰²ï¼ŒæŒ‰å››ä¸ªæ–¹å‘æŽ’å¸ƒï¼šæ­£é¢ã€èƒŒé¢ã€å·¦ä¾§ã€å³ä¾§ï¼Œæ¯ä¸ªæ–¹å‘一个完整角色,方å‘之间相互分离ã€äº’ä¸é‡å ã€äº’ä¸æŽ¥è§¦ã€‚ +四个方å‘必须是åŒä¸€ä¸ªè§’色,脸型ã€å‘åž‹ã€æœè£…ã€é…è‰²ã€æ­¦å™¨ã€å¤´èº«æ¯”ã€ä½“åž‹å…¨ç¨‹ä¸€è‡´ï¼Œç¦æ­¢æ¼‚ç§»ã€æ¢è£…ã€å˜å½¢ã€‚ +æ¯ä¸ªæ–¹å‘角色完整居中,ä¸è£åˆ‡å¤´é¡¶ã€æ‰‹è„šæˆ–æ­¦å™¨ï¼›å››ä¸ªæ–¹å‘底部基线对é½ï¼Œç”»é¢å æ¯”一致。 +背景为å•一纯色(绿幕 #00FF00 / 白底 #FFFFFF),平整无纹ç†ã€æ— æ¸å˜ã€æ— é˜´å½±ã€æ— åœ°é¢ã€æ— é“具;角色之间由纯色背景间隔,便于åŽç»­æ‹†åˆ†ã€‚ +ç¦æ­¢é¢å¤–角色ã€åˆ†èº«ã€å® ç‰©ã€æ–‡å­—ã€æ°´å°ã€è¾¹æ¡†ã€ç¼–å·ã€ç½‘格线。 +角色设定:{} +``` + +- å‚考归属校验:å¤ç”¨ `resolve_editor_reference_object_key_for_owner` / `ensure_editor_reference_object_key_owned`ï¼Œç¦æ­¢è·¨è´¦å· objectKey。 + +## 5. 使用æµç¨‹ï¼ˆå…¨éƒ¨çŽ°æœ‰ï¼‰ + +1. 生æˆå¤šæ–¹å‘角色图 → 得到 `icon-spritesheet` 图集图层。 +2. 选中图集 → 点「拆分图集ã€ï¼ˆçŽ°æœ‰æŒ‰é’®ï¼‰â†’ 得到若干å•视角图。 +3. 对æ¯ä¸ªæƒ³ç”¨çš„æ–¹å‘,选对应å•视角图 → åŠ¨ç”»é¢æ¿ç”Ÿæˆï¼ˆé»˜è®¤å³è§†å›¾èµ·æ­¥ï¼‰ã€‚ + +## 6. 边界 + +- 生æˆè´¨é‡é æç¤ºè¯ï¼›å›¾ä¸å¯¹ → ç”¨æˆ·é‡æ–°æŠ½å¡ï¼Œ**ä¸åšæ•°é‡/æ–¹å‘æ ¡éªŒ**。 +- 连通å—若粘连(方å‘贴太近)→ 拆分数é‡ä¸å¯¹ → 釿–°æŠ½å¡æˆ–人工处ç†ï¼Œä¸è‡ªåŠ¨å…œåº•ã€‚ +- æˆæœ¬ï¼šå¤šå‘图一次图åƒç”Ÿæˆï¼›æ‹†åˆ† 0 provider æˆæœ¬ï¼›æ¯ä¸ªæ–¹å‘动画å„一次(现有计价)。 +- 旧链路(直接拿å•视角角色图åšåŠ¨ç”»ï¼‰ä¸å˜ã€‚ + +## 7. 验收 + +1. 从角色设定能生æˆä¸€å¼ å¤šæ–¹å‘è§’è‰²å›¾ï¼Œå„æ–¹å‘为åŒä¸€è§’色。 +2. 该图å¯ä½œä¸ºå›¾é›†æ‰‹åŠ¨æ‹†åˆ†ï¼Œæ‹†å‡ºçš„å•è§†è§’å›¾èƒ½è¿›åŠ¨ç”»é¢æ¿ç”Ÿæˆå•角色åºåˆ—帧。 +3. 旧链路回归ä¸ç ´ã€‚ + +## 8. 决策点(留给产å“) + +- 四视图 vs å…«æ–¹å‘(当å‰å»ºè®®å››è§†å›¾ï¼šå‰/åŽ/å·¦/å³ï¼‰ã€‚ +- å…¥å£ä½ç½®ï¼ˆã€Œç”Ÿæˆè§’色形象ã€é¢æ¿æŒ‰é’® vs å¤ç”¨å›¾æ ‡å›¾é›†é¢æ¿ï¼‰ã€‚ +- 多å‘图背景色(绿幕/白底)。 + +## 9. 改动文件 + +- å‰ç«¯ï¼š`ImageCanvasGenerationModel.ts`(多å‘图æç¤ºè¯/常é‡ï¼‰ã€`ImageCanvasGenerationDialogModel.ts`ã€`ImageCanvasGenerationSubmissionModel.ts`ã€`ImageCanvasGenerationComposerView.tsx`ï¼ˆå…¥å£æŒ‰é’®ï¼‰ã€‚ +- åŽç«¯ï¼š`editor_project_icon.rs`(多å‘å›¾ç”Ÿæˆæç¤ºè¯åˆ†æ”¯ï¼‰æˆ– `editor_project.rs`ï¼›`shared-contracts`(如需 DTO 字段)。 +- 无需动拆分逻辑ã€åŠ¨ç”»é€»è¾‘ã€å­˜å‚¨å±‚。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index cef77e705..f94f1678b 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -371,6 +371,7 @@ impl EditorAgentTool for GenerateImageTool { seg_model: None, aspect_ratio: Some(args.aspect_ratio), image_size: Some(args.image_size), + character_sheet_views: None, reference_image_srcs: Some(reference_image_srcs), project_id: Some(context.conversation.project_id.clone()), asset_kind: Some("editor_agent_generated_image".to_string()), @@ -479,6 +480,7 @@ impl EditorAgentTool for GenerateCharacterTool { seg_model: Some("birefnet".to_string()), aspect_ratio: Some(args.aspect_ratio), image_size: Some(args.image_size), + character_sheet_views: None, reference_image_srcs: Some(reference_image_srcs), project_id: Some(context.conversation.project_id.clone()), asset_kind: Some("character".to_string()), @@ -589,6 +591,7 @@ impl EditorAgentTool for GenerateUiDesignTool { seg_model: None, aspect_ratio: Some(args.aspect_ratio), image_size: Some(args.image_size), + character_sheet_views: None, reference_image_srcs: Some(reference_image_srcs), project_id: Some(context.conversation.project_id.clone()), asset_kind: Some("ui-design".to_string()), diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 609a325d6..5828ec721 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -350,6 +350,9 @@ pub struct EditorImageGenerationRequest { pub(crate) seg_model: Option, pub(crate) aspect_ratio: Option, pub(crate) image_size: Option, + /// 角色多方å‘图集的视角数:4 或 8;仅 kind=character-sheet æ—¶ç”Ÿæ•ˆï¼Œç¼ºçœ 4。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) character_sheet_views: Option, pub(crate) reference_image_srcs: Option>, pub(crate) project_id: Option, pub(crate) asset_kind: Option, @@ -2518,6 +2521,7 @@ pub async fn generate_editor_scene( seg_model: None, aspect_ratio: Some(generation_options.aspect_ratio.to_string()), image_size: Some(generation_options.image_size.to_string()), + character_sheet_views: None, reference_image_srcs: (!payload.reference_image_srcs.is_empty()) .then_some(payload.reference_image_srcs), project_id: payload.project_id, @@ -2816,6 +2820,17 @@ where )?; let normalized_kind = payload.kind.as_deref().map(str::trim); let is_character_generation = matches!(normalized_kind, Some("character")); + let is_character_sheet_generation = matches!(normalized_kind, Some("character-sheet")); + if is_character_sheet_generation + && !matches!(payload.character_sheet_views.unwrap_or(4), 4 | 8) + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-generation", + "message": "characterSheetViews åªæ”¯æŒ 4 或 8。", + })), + ); + } let pixel_art_supported = matches!(normalized_kind, None | Some("") | Some("character")); let (image_style, mut generation_warning) = normalize_editor_image_generation_style(payload.style.as_deref(), pixel_art_supported); @@ -3023,6 +3038,19 @@ where role_setting.as_str(), screen_color.expect("character generation should have screen color"), ) + } else if is_character_sheet_generation { + // 多方å‘å›¾é›†ä»¥çº¯ç»¿åº•é—´éš”å„æ–¹å‘ï¼Œä¾›è¿žé€šå—æ‹†åˆ†ï¼›ä¸å‚与编辑器的背景自动选色。 + build_editor_character_multi_view_sheet_prompt( + role_setting.as_str(), + crate::editor_green_screen::EditorScreenBackgroundColor { + label: "纯绿色", + hex: "#00FF00", + red: 0, + green: 255, + blue: 0, + }, + payload.character_sheet_views.unwrap_or(4), + ) } else if is_ui_design_generation { build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references) } else { @@ -3543,6 +3571,7 @@ fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) -> match normalized_kind { Some("scene") => EDITOR_SCENE_IMAGE_ASSET_KIND, Some("character") => EDITOR_CHARACTER_IMAGE_ASSET_KIND, + Some("character-sheet") => EDITOR_CHARACTER_IMAGE_ASSET_KIND, Some("spec") => EDITOR_SPEC_IMAGE_ASSET_KIND, Some("quick-edit") => EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND, Some("ui-design") => EDITOR_UI_DESIGN_IMAGE_ASSET_KIND, @@ -3637,6 +3666,11 @@ fn editor_generated_image_storage_profile( path_kind: "character-images", slot: EDITOR_CHARACTER_IMAGE_SLOT, }, + Some("character-sheet") => EditorGeneratedImageStorageProfile { + asset_kind: EDITOR_CHARACTER_IMAGE_ASSET_KIND, + path_kind: "character-sheet-images", + slot: "character-sheet", + }, Some("spec") => EditorGeneratedImageStorageProfile { asset_kind: EDITOR_SPEC_IMAGE_ASSET_KIND, path_kind: "spec-images", @@ -7799,6 +7833,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( ), path_kind: "ui-design-assets", asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, + record_asset_kind: "icon", persistence_provider: "vector-engine", }, ) @@ -10854,6 +10889,30 @@ fn build_editor_character_image_prompt( .join("\n") } +/// 多方å‘角色图集æç¤ºè¯ï¼šä¸€å¼ å›¾é‡ŒæŽ’布åŒä¸€è§’色的多个方å‘ï¼Œå„æ–¹å‘独立æˆå—ã€çº¯è‰²åº•间隔, +/// 供用户手动「拆分图集ã€åŽé€è§†è§’生æˆåŠ¨ç”»ã€‚æ–¹å‘/æ•°é‡ä¸æ ¡éªŒï¼Œå›¾ä¸å¯¹ç”±ç”¨æˆ·é‡æ–°ç”Ÿæˆã€‚ +fn build_editor_character_multi_view_sheet_prompt( + role_setting: &str, + screen_color: EditorScreenBackgroundColor, + view_count: u32, +) -> String { + let (view_count_text, view_layout) = match view_count { + 8 => ("å…«", "æ­£é¢ã€å³å‰ã€å³ä¾§ã€å³åŽã€èƒŒé¢ã€å·¦åŽã€å·¦ä¾§ã€å·¦å‰"), + _ => ("å››", "æ­£é¢ã€èƒŒé¢ã€å·¦ä¾§ã€å³ä¾§"), + }; + vec![ + format!( + "æ ¹æ®è§’è‰²è®¾å®šç”Ÿæˆæ¸¸æˆè§’色的多方å‘视图图集。画é¢ä¸­åªæœ‰åŒä¸€ä¸ªè§’色,按{view_count_text}ä¸ªæ–¹å‘æŽ’å¸ƒï¼š{view_layout},æ¯ä¸ªæ–¹å‘一个完整角色,方å‘之间相互分离ã€äº’ä¸é‡å ã€äº’ä¸æŽ¥è§¦ã€‚\ +{view_count_text}个方å‘必须是åŒä¸€ä¸ªè§’色,脸型ã€å‘åž‹ã€æœè£…ã€é…è‰²ã€æ­¦å™¨ã€å¤´èº«æ¯”ã€ä½“åž‹å…¨ç¨‹ä¸€è‡´ï¼Œç¦æ­¢æ¼‚ç§»ã€æ¢è£…ã€å˜å½¢ã€‚\ +æ¯ä¸ªæ–¹å‘角色完整居中,ä¸è£åˆ‡å¤´é¡¶ã€æ‰‹è„šæˆ–æ­¦å™¨ï¼›å„æ–¹å‘底部基线对é½ï¼Œç”»é¢å æ¯”一致。{};角色之间由纯色背景间隔,便于åŽç»­æ‹†åˆ†ã€‚\ +ç¦æ­¢é¢å¤–角色ã€åˆ†èº«ã€å® ç‰©ã€æ–‡å­—ã€æ°´å°ã€è¾¹æ¡†ã€ç¼–å·ã€ç½‘格线。", + editor_green_screen_character_prompt_clause(screen_color) + ), + format!("角色设定:{}", role_setting.trim()), + ] + .join("\n") +} + /// 中文注释:`style="pixelArt"` æ­¤å‰åªé©±åЍ provider 返回åŽçš„确定性åƒç´ è§„整,完全ä¸å‚与æç¤ºè¯ã€‚ /// snapper 是几何对é½å™¨â€”â€”æ£€æµ‹ç½‘æ ¼æ­¥é•¿åŽæŒ‰æ ¼é‡é‡‡æ ·ï¼›provider 交一张柔和æ¸å˜å›¾æ—¶ä¸¤è½´éƒ½æµ‹ä¸åˆ° /// 步长,生æˆè·¯å¾„用的 legacy profile 会退到 `min(w,h)/64` ç»Ÿä¸€ç½‘æ ¼ï¼Œäº§å‡ºçš„æ˜¯é©¬èµ›å…‹è€Œä¸æ˜¯åƒç´  @@ -16548,6 +16607,60 @@ mod tests { assert!(prompt.contains("角色设定:èœå¸‚场å–èœå¤§å¦ˆ")); } + #[test] + fn editor_character_multi_view_sheet_prompt_enforces_directions_and_separation() { + let prompt = build_editor_character_multi_view_sheet_prompt( + "日系魔法少女", + crate::editor_green_screen::EditorScreenBackgroundColor { + label: "纯绿色", + hex: "#00FF00", + red: 0, + green: 255, + blue: 0, + }, + 4, + ); + + assert!(prompt.contains("多方å‘视图图集")); + assert!(prompt.contains("å››ä¸ªæ–¹å‘æŽ’å¸ƒï¼šæ­£é¢ã€èƒŒé¢ã€å·¦ä¾§ã€å³ä¾§")); + assert!(prompt.contains("æ–¹å‘之间相互分离ã€äº’ä¸é‡å ã€äº’ä¸æŽ¥è§¦")); + assert!(prompt.contains("脸型ã€å‘åž‹ã€æœè£…ã€é…è‰²ã€æ­¦å™¨ã€å¤´èº«æ¯”ã€ä½“型全程一致")); + assert!(prompt.contains("背景固定为å•一纯色背景 纯绿色 #00FF00")); + assert!(prompt.contains("角色之间由纯色背景间隔,便于åŽç»­æ‹†åˆ†")); + assert!(prompt.contains("ç¦æ­¢é¢å¤–角色ã€åˆ†èº«ã€å® ç‰©ã€æ–‡å­—ã€æ°´å°ã€è¾¹æ¡†ã€ç¼–å·ã€ç½‘格线")); + assert!(prompt.contains("角色设定:日系魔法少女")); + } + + #[test] + fn editor_character_multi_view_sheet_prompt_supports_eight_directions() { + let prompt = build_editor_character_multi_view_sheet_prompt( + "日系魔法少女", + crate::editor_green_screen::EditorScreenBackgroundColor { + label: "纯绿色", + hex: "#00FF00", + red: 0, + green: 255, + blue: 0, + }, + 8, + ); + + assert!(prompt.contains("å…«ä¸ªæ–¹å‘æŽ’å¸ƒï¼šæ­£é¢ã€å³å‰ã€å³ä¾§ã€å³åŽã€èƒŒé¢ã€å·¦åŽã€å·¦ä¾§ã€å·¦å‰")); + assert!(prompt.contains("八个方å‘必须是åŒä¸€ä¸ªè§’色")); + assert!(prompt.contains("æ¯ä¸ªæ–¹å‘角色完整居中")); + } + + #[test] + fn editor_character_sheet_kind_routes_to_sheet_prompt_and_icon_spritesheet_asset_kind() { + assert_eq!( + editor_image_generation_billing_asset_kind(Some("character-sheet")), + EDITOR_CHARACTER_IMAGE_ASSET_KIND + ); + let profile = editor_generated_image_storage_profile(Some("character-sheet")); + assert_eq!(profile.path_kind, "character-sheet-images"); + assert_eq!(profile.slot, "character-sheet"); + } + #[test] fn editor_generated_asset_persistence_keeps_user_prompt_separate_from_system_prompt() { let source = concat!( diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 78aa5dd26..5126d4b39 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -638,6 +638,7 @@ fn build_icon_spec_image_request( seg_model: None, aspect_ratio: Some(ICON_SPEC_ASPECT_RATIO.to_string()), image_size: Some(ICON_SPEC_IMAGE_SIZE.to_string()), + character_sheet_views: None, reference_image_srcs: payload.reference_id.map(|reference_id| vec![reference_id]), project_id: payload.project_id, asset_kind: Some("icon-spec".to_string()), @@ -840,6 +841,9 @@ pub(crate) struct PersistEditorSpritesheetSlicesInput { pub(crate) generation_inputs: Option, pub(crate) path_kind: &'static str, pub(crate) asset_kind: &'static str, + /// 切片资æº/ç´ æè®°å½•çš„ asset_kind(å‰ç«¯æ®æ­¤åˆ¤æ–­ç´ æè¯­ä¹‰ï¼‰ã€‚图标图集默认 "icon"ï¼› + /// 角色多方å‘图集拆分传 "character",使å•视角切片å¯ç›´æŽ¥ä½œä¸ºè§’色动画å‚考。 + pub(crate) record_asset_kind: &'static str, pub(crate) persistence_provider: &'static str, } @@ -960,7 +964,7 @@ pub(crate) async fn persist_editor_spritesheet_slices( provider: Some(input.provider.clone()), task_id: Some(input.task_id.clone()), source_resource_id: normalize_optional_string(input.source_resource_id.clone()), - asset_kind: Some("icon".to_string()), + asset_kind: Some(input.record_asset_kind.to_string()), generation_inputs_json: generation_inputs_json.clone(), updated_at_micros: now_micros, image_sequence_frames_json: None, @@ -990,7 +994,7 @@ pub(crate) async fn persist_editor_spritesheet_slices( model: Some(input.model.clone()), provider: Some(input.provider.clone()), task_id: Some(input.task_id.clone()), - asset_kind: Some("icon".to_string()), + asset_kind: Some(input.record_asset_kind.to_string()), generation_inputs_json: generation_inputs_json.clone(), source_resource_id: resource_id.clone(), generation_cost_mud_points: 0, @@ -1963,6 +1967,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ), path_kind: "icon-spritesheet-assets", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, + record_asset_kind: "icon", persistence_provider: "vector-engine", }, ) @@ -2170,6 +2175,17 @@ pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind<'a>( } } +/// 图集切片记录 asset_kind:角色多方å‘图集(action=character-sheet.generate)拆出的 +/// å•视角切片按 character è½åº“(å¯ç›´æŽ¥ä½œåŠ¨ç”»å‚考),其余图集按 icon。 +fn editor_spritesheet_slice_record_asset_kind(source_generation_inputs: Option<&Value>) -> &'static str { + source_generation_inputs + .and_then(|inputs| inputs.get("action")) + .and_then(Value::as_str) + .is_some_and(|action| action == "character-sheet.generate") + .then_some("character") + .unwrap_or("icon") +} + pub async fn split_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, @@ -2309,6 +2325,10 @@ pub async fn split_editor_icon_spritesheet( .clone() .unwrap_or_else(|| "Genarrative".to_string()); let generation_inputs = source_resource.generation_inputs.clone(); + // 中文注释:角色多方å‘图集(action=character-sheet.generate)拆分出的å•视角切片按 + // character è½åº“,使其å¯ç›´æŽ¥ä½œä¸ºè§’色动画å‚è€ƒï¼›æ™®é€šå›¾æ ‡å›¾é›†ä»æŒ‰ icon。 + let slice_record_asset_kind = + editor_spritesheet_slice_record_asset_kind(source_resource.generation_inputs.as_ref()); let icon_image_srcs = persist_editor_spritesheet_slices( &state, slices, @@ -2328,6 +2348,7 @@ pub async fn split_editor_icon_spritesheet( generation_inputs, path_kind: "icon-spritesheet-slices", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, + record_asset_kind: slice_record_asset_kind, persistence_provider: "genarrative", }, ) @@ -2677,6 +2698,32 @@ pub(crate) fn validate_editor_icon_spritesheet_dimensions( mod tests { use super::*; + #[test] + fn sprite_slice_record_asset_kind_marks_character_sheet_slices_as_character() { + assert_eq!( + editor_spritesheet_slice_record_asset_kind(None), + "icon" + ); + assert_eq!( + editor_spritesheet_slice_record_asset_kind(Some(&json!({ + "action": "icon.generate" + }))), + "icon" + ); + assert_eq!( + editor_spritesheet_slice_record_asset_kind(Some(&json!({ + "action": "character-sheet.generate" + }))), + "character" + ); + assert_eq!( + editor_spritesheet_slice_record_asset_kind(Some(&json!({ + "version": 2 + }))), + "icon" + ); + } + #[test] fn game_genre_serde_uses_chinese_slugs() { for genre in GameGenre::ALL { diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx index 8946494bc..f19664276 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx @@ -90,6 +90,12 @@ function CharacterGenerationHarness({ {dialog.prompt} {dialog.status} {dialog.errorMessage ?? '-'} + + {String(dialog.characterSheetEnabled === true)} + + + {dialog.characterSheetViewSet ?? '4'} + {String(isPickingSpec)} {String(isPickingReference)} @@ -122,6 +128,64 @@ describe('ImageCanvasCharacterGenerationComposerView', () => { ); }); + it('toggles the multi-direction character sheet option', () => { + render( + , + ); + + const toggle = screen.getByRole('button', { name: '多方å‘角色图' }); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + + fireEvent.click(toggle); + + expect(screen.getByLabelText('当å‰å¤šæ–¹å‘角色图').textContent).toBe('true'); + expect( + screen.getByRole('button', { name: '多方å‘角色图' }).getAttribute( + 'aria-pressed', + ), + ).toBe('true'); + + fireEvent.click(screen.getByRole('button', { name: '多方å‘角色图' })); + expect(screen.getByLabelText('当å‰å¤šæ–¹å‘角色图').textContent).toBe('false'); + }); + + it('shows the view-set dropdown only while the sheet is enabled', () => { + render( + , + ); + + expect(screen.queryByRole('button', { name: /多方å‘图视角/ })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '多方å‘角色图' })); + + expect( + screen.getByRole('button', { name: '多方å‘图视角 四方å‘' }), + ).toBeTruthy(); + }); + + it('switches the character sheet view set between four and eight', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '多方å‘图视角 四方å‘' })); + fireEvent.click(screen.getByRole('button', { name: 'å…«æ–¹å‘' })); + + expect(screen.getByLabelText('当å‰å¤šæ–¹å‘图视角数').textContent).toBe('8'); + expect( + screen.getByRole('button', { name: '多方å‘图视角 å…«æ–¹å‘' }), + ).toBeTruthy(); + }); + it('updates character prompt, clears failed state and submits', () => { const submitCharacter = vi.fn(); render( diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx index 4fc57e50b..b77d8ef1c 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx @@ -1,10 +1,12 @@ -import { ClipboardList, ImagePlus } from 'lucide-react'; +import { ChevronDown, ClipboardList, ImagePlus, LayoutGrid } from 'lucide-react'; import { type CSSProperties, type Dispatch, type ReactNode, type RefObject, type SetStateAction, + useRef, + useState, } from 'react'; import { AutoGrowTextArea } from '../common/AutoGrowTextArea'; @@ -12,6 +14,7 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; +import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import type { CharacterReferenceImage, @@ -101,6 +104,8 @@ export function ImageCanvasCharacterGenerationComposerView({ hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasCharacterGenerationComposerViewProps) { + const [isViewSetMenuOpen, setIsViewSetMenuOpen] = useState(false); + const viewSetButtonRef = useRef(null); useImageCanvasFloatingOptionDismiss({ isOpen: isCharacterSpecMenuOpen || isCharacterReferenceMenuOpen, boundaryRefs: [characterSpecButtonRef, characterReferenceButtonRef], @@ -109,6 +114,25 @@ export function ImageCanvasCharacterGenerationComposerView({ setIsCharacterReferenceMenuOpen(false); }, }); + useImageCanvasFloatingOptionDismiss({ + isOpen: isViewSetMenuOpen, + boundaryRefs: [viewSetButtonRef], + onDismiss: () => setIsViewSetMenuOpen(false), + restoreFocusRef: viewSetButtonRef, + }); + const setViewSet = (value: '4' | '8') => { + setIsViewSetMenuOpen(false); + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'character' + ? { + ...resetFailedDialogStatus(currentDialog), + characterSheetViewSet: value, + } + : currentDialog, + ); + }; + const selectedViewSetLabel = + (dialog.characterSheetViewSet ?? '4') === '8' ? 'å…«æ–¹å‘' : '四方å‘'; return (
setIsCharacterReferenceMenuOpen((open) => !open)} /> +
diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 77606d46e..92bbb03e0 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -82,6 +82,7 @@ export type CanvasGenerationAction = | 'scene.generate' | 'spec.generate' | 'character.generate' + | 'character-sheet.generate' | 'icon.generate' | 'ui-design.generate' | 'publication.generate' @@ -278,6 +279,11 @@ export type GenerateDialogState = { generationReferences?: CharacterReferenceImage[]; characterSpecReference?: CharacterReferenceImage | null; characterReferences?: CharacterReferenceImage[]; + // ä¸­æ–‡æ³¨é‡Šï¼šè§’è‰²é¢æ¿çš„"多方å‘角色图"å¼€å…³â€”â€”å¼€å¯æ—¶ç”Ÿæˆä¸€å¼ å››æ–¹å‘角色图集 + // (åŽç«¯ kind=character-sheet,产物按 icon-spritesheet è½åº“,å¯èµ°æ—¢æœ‰æ‹†åˆ†å›¾é›†ï¼‰ã€‚ + characterSheetEnabled?: boolean; + // 中文注释:多方å‘角色图的视角数(4 或 8),仅 characterSheetEnabled 时生效。 + characterSheetViewSet?: '4' | '8'; iconSpecReference?: CharacterReferenceImage | null; iconDescriptions?: string[]; publicationWorkflowId?: PublicationMaterialsWorkflowId; diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 0b48914ab..962021fab 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -1228,6 +1228,41 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); + it('restores a multi-direction character sheet recipe into the character composer', () => { + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + id: 'layer-sheet-result', + resourceId: 'resource-sheet-result', + sourceType: 'generated', + assetKind: 'icon-spritesheet', + generationInputs: { + version: 2, + action: 'character-sheet.generate', + fields: [ + { id: 'prompt', title: '角色设定', value: '日系魔法少女' }, + { id: 'model', title: '模型', value: 'nanobanana2' }, + { id: 'viewSet', title: '视角集åˆ', value: '八视图' }, + { id: 'aspectRatio', title: '比例', value: '1:1' }, + { id: 'imageSize', title: '尺寸', value: '1K' }, + ], + references: [], + }, + }), + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + mode: 'redraw', + }), + ).toMatchObject({ + mode: 'character', + prompt: '日系魔法少女', + sourceLayerId: 'layer-sheet-result', + style: 'none', + characterSheetEnabled: true, + characterSheetViewSet: '8', + }); + }); + it('maps legacy ui spec.generate inputs to the unified icon spec composer', () => { expect( createSameSourceGenerationDialogDraft({ diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 25384baa8..51ef28256 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -316,6 +316,8 @@ export function createCharacterGenerationDialogDraft({ style: 'none', characterSpecReference: null, characterReferences: [], + characterSheetEnabled: false, + characterSheetViewSet: '4', imageModel: normalizedImageModel, aspectRatio: dimensionDefaults.aspectRatio, imageSize: dimensionDefaults.imageSize, @@ -1061,6 +1063,41 @@ function createNormalizedGenerationDialogDraft({ ); } + if (inputs.action === 'character-sheet.generate') { + // 多方å‘è§’è‰²å›¾å›žå¼¹åˆ°è§’è‰²é¢æ¿å¹¶æ¢å¤å›¾é›†æ¨¡å¼ä¸Žè§†è§’数。 + return placeDraftBesideSourceLayer( + resizeGenerationPlaceholderToImageSelection({ + ...createCharacterGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: getNormalizedString( + sourceLayer, + 'model', + DEFAULT_IMAGE_MODEL, + ), + }), + prompt, + sourceLayerId: sourceLayer.id, + style: 'none', + aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'), + imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), + characterSheetEnabled: true, + characterSheetViewSet: + getNormalizedString(sourceLayer, 'viewSet') === '八视图' + ? '8' + : '4', + characterSpecReference: + resolveNormalizedReferences( + sourceLayer, + 'specReference', + availableLayers, + )[0] ?? null, + characterReferences: references, + }), + sourceLayer, + ); + } + if (inputs.action === 'icon.generate') { return placeDraftBesideSourceLayer( resizeGenerationPlaceholderToImageSelection({ diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts index 463308b1e..f992ef982 100644 --- a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -17,6 +17,7 @@ export const CANVAS_GENERATION_ACTIONS = [ 'scene.generate', 'spec.generate', 'character.generate', + 'character-sheet.generate', 'icon.generate', 'ui-design.generate', 'publication.generate', @@ -37,6 +38,7 @@ const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set([ 'scene.generate', 'spec.generate', 'character.generate', + 'character-sheet.generate', 'icon.generate', 'ui-design.generate', 'publication.generate', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 6480ede9a..0edba945b 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -2174,6 +2174,58 @@ export function buildCharacterGenerationInputs( }; } +export function buildCharacterSheetGenerationInputs( + prompt: string, + specReference: CharacterReferenceImage | null | undefined, + references: CharacterReferenceImage[] | undefined, + options: { + model?: string; + aspectRatio?: string; + imageSize?: string; + viewSet?: '4' | '8'; + } = {}, +): CanvasGenerationInputs { + return { + version: 2, + action: 'character-sheet.generate', + fields: [ + ...createGenerationInputField('角色设定', prompt, 'prompt'), + ...createGenerationInputField( + '模型', + options.model ?? DEFAULT_IMAGE_MODEL, + 'model', + ), + ...createGenerationInputField( + '视角集åˆ', + options.viewSet === '8' ? '八视图' : '四视图', + 'viewSet', + ), + ...createGenerationInputField( + '比例', + options.aspectRatio ?? '1:1', + 'aspectRatio', + ), + ...createGenerationInputField( + '尺寸', + options.imageSize ?? '1K', + 'imageSize', + ), + ], + references: [ + ...(specReference + ? createGenerationInputReference('角色规范', specReference, { + id: 'specReference', + }) + : []), + ...(references ?? []).flatMap((reference, index) => + createGenerationInputReference(`常规å‚考图 ${index + 1}`, reference, { + id: 'reference', + }), + ), + ], + }; +} + export function buildUiDesignGenerationInputs( prompt: string, specReference: CharacterReferenceImage | null | undefined, diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 8e9c9e244..bcd432090 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -642,6 +642,87 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ); }); + it('builds character-sheet plans when multi-direction sheet is enabled', () => { + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'character', + prompt: '日系魔法少女', + status: 'idle', + characterSheetEnabled: true, + characterSpecReference: { + id: 'spec', + label: '角色规范', + src: 'data:image/png;base64,spec', + resourceId: 'resource-character-spec', + }, + }, + layers: [], + nextGeneratedIndex: 3, + }); + + expect(plan).toMatchObject({ + kind: 'image', + normalizedPrompt: '日系魔法少女', + input: { + prompt: '日系魔法少女', + kind: 'character-sheet', + assetKind: 'icon-spritesheet', + style: 'none', + }, + result: { + assetKind: 'icon-spritesheet', + title: '角色多方å‘图 3', + }, + }); + expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( + expect.objectContaining({ + version: 2, + action: 'character-sheet.generate', + fields: expect.arrayContaining([ + { id: 'prompt', title: '角色设定', value: '日系魔法少女' }, + { id: 'viewSet', title: '视角集åˆ', value: '四视图' }, + ]), + }), + ); + expect(plan.kind === 'image' ? plan.input : null).toMatchObject({ + characterSheetViews: 4, + }); + // 图集ä¸åšåƒç´ åŒ–ã€ä¸åŽ»èƒŒæ™¯ï¼ŒscreenColor/segModel 䏿ºå¸¦ã€‚ + expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty( + 'screenColor', + ); + expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty( + 'segModel', + ); + }); + + it('builds eight-direction character-sheet plans when view set is eight', () => { + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'character', + prompt: '日系魔法少女', + status: 'idle', + characterSheetEnabled: true, + characterSheetViewSet: '8', + }, + layers: [], + nextGeneratedIndex: 4, + }); + + expect(plan.kind === 'image' ? plan.input : null).toMatchObject({ + kind: 'character-sheet', + characterSheetViews: 8, + }); + expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( + expect.objectContaining({ + action: 'character-sheet.generate', + fields: expect.arrayContaining([ + { id: 'viewSet', title: '视角集åˆ', value: '八视图' }, + ]), + }), + ); + }); + it('locks UI design generation submissions to gpt-image-2', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 049baa77f..a825ef917 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -26,6 +26,7 @@ import type { import { buildBackgroundMusicGenerationInputs, buildCharacterGenerationInputs, + buildCharacterSheetGenerationInputs, buildEditGenerationInputs, buildIconGenerationInputs, buildImageGenerationInputs, @@ -800,37 +801,64 @@ export function buildImageGenerationSubmissionPlan({ const imageModel = normalizeEditorImageModel(dialog.imageModel); const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR; const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL; + const sheetEnabled = dialog.characterSheetEnabled === true; + const sheetViewSet = dialog.characterSheetViewSet ?? '4'; + // 中文注释:多方å‘角色图走åŽç«¯ kind=character-sheetï¼ˆç»¿è‰²é—´éš”åº•ï¼Œä¾¿äºŽè¿žé€šå—æ‹†åˆ†ï¼‰ï¼Œ + // 产物按 icon-spritesheet è½åº“,å¤ç”¨æ—¢æœ‰ã€Œæ‹†åˆ†å›¾é›†ã€èƒ½åŠ›ï¼›ä¸åšåƒç´ åŒ–ã€ä¸åŽ»èƒŒæ™¯ã€‚ return { kind: 'image', normalizedPrompt, input: { prompt: normalizedPrompt, - kind: 'character', + kind: sheetEnabled ? 'character-sheet' : 'character', model: imageModel, - screenColor, - segModel, - style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none', + ...(sheetEnabled ? {} : { screenColor, segModel }), + style: sheetEnabled + ? 'none' + : dialog.style === 'pixelArt' + ? 'pixelArt' + : 'none', aspectRatio: dialog.aspectRatio ?? '1:1', imageSize: dialog.imageSize ?? '1K', + ...(sheetEnabled + ? { + assetKind: 'icon-spritesheet', + characterSheetViews: sheetViewSet === '8' ? 8 : 4, + } + : {}), ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), }, result: { - assetKind: 'character', + assetKind: sheetEnabled ? 'icon-spritesheet' : 'character', title: resolveGenerationAssetLabel( dialog.assetLabel, - `角色形象 ${nextGeneratedIndex}`, - ), - generationInputs: buildCharacterGenerationInputs( - normalizedPrompt, - dialog.characterSpecReference, - dialog.characterReferences, - { - model: imageModel, - style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none', - aspectRatio: dialog.aspectRatio ?? '1:1', - imageSize: dialog.imageSize ?? '1K', - }, + sheetEnabled + ? `角色多方å‘图 ${nextGeneratedIndex}` + : `角色形象 ${nextGeneratedIndex}`, ), + generationInputs: sheetEnabled + ? buildCharacterSheetGenerationInputs( + normalizedPrompt, + dialog.characterSpecReference, + dialog.characterReferences, + { + model: imageModel, + aspectRatio: dialog.aspectRatio ?? '1:1', + imageSize: dialog.imageSize ?? '1K', + viewSet: sheetViewSet, + }, + ) + : buildCharacterGenerationInputs( + normalizedPrompt, + dialog.characterSpecReference, + dialog.characterReferences, + { + model: imageModel, + style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none', + aspectRatio: dialog.aspectRatio ?? '1:1', + imageSize: dialog.imageSize ?? '1K', + }, + ), }, rememberImageModel: imageModel, }; diff --git a/src/components/image-editor/ImageCanvasReferenceSlot.tsx b/src/components/image-editor/ImageCanvasReferenceSlot.tsx index f0304f6e2..849f48311 100644 --- a/src/components/image-editor/ImageCanvasReferenceSlot.tsx +++ b/src/components/image-editor/ImageCanvasReferenceSlot.tsx @@ -11,7 +11,8 @@ type ImageCanvasReferenceSlotTone = | 'character' | 'quick-edit' | 'video' - | 'audio'; + | 'audio' + | 'character-sheet'; type ImageCanvasReferenceSlotProps = { buttonRef?: RefObject; @@ -25,6 +26,8 @@ type ImageCanvasReferenceSlotProps = { objectKey?: string | null; disabled?: boolean; isAdd?: boolean; + /** 选中æ€ï¼ˆç”¨äºŽæ¨¡å¼å¼€å…³ç±»å…¥å£ï¼‰ï¼šç½®ä½æ—¶é«˜äº®è¾¹æ¡†å¹¶å¸¦ aria-pressed。 */ + pressed?: boolean; onClick?: () => void; onRemove?: () => void; removeLabel?: string; @@ -42,6 +45,7 @@ export function ImageCanvasReferenceSlot({ objectKey, disabled = false, isAdd = false, + pressed, onClick, onRemove, removeLabel, @@ -54,11 +58,11 @@ export function ImageCanvasReferenceSlot({ ? 'image-canvas-editor__reference-chip--has-image' : 'image-canvas-editor__reference-chip--icon-only', isAdd ? 'image-canvas-editor__reference-chip--upload' : null, + pressed ? 'image-canvas-editor__reference-chip--active' : null, className, ] .filter(Boolean) - .join(' '); - const slotContent = ( + .join(' '); const slotContent = ( <> {hasPreviewImage ? ( @@ -89,6 +93,7 @@ export function ImageCanvasReferenceSlot({ className={slotClassName} disabled={disabled} aria-label={ariaLabel} + aria-pressed={pressed === undefined ? undefined : pressed} title={title ?? ariaLabel} onClick={onClick} > diff --git a/src/index.css b/src/index.css index 1bb7bdece..c778e0dac 100644 --- a/src/index.css +++ b/src/index.css @@ -7226,6 +7226,12 @@ button.image-canvas-editor__reference-chip:disabled { white-space: nowrap; } +.image-canvas-editor__option-cluster--viewset { + width: auto; + max-width: 6.5rem; + justify-self: start; +} + .image-canvas-editor__option-popover-anchor { position: relative; min-width: 0; @@ -7719,6 +7725,22 @@ button.image-canvas-editor__reference-chip:disabled { grid-column: 1 / -1; } +.image-canvas-editor__reference-chip--character-sheet + .image-canvas-editor__reference-chip-icon { + color: #db2777; +} + +.image-canvas-editor__reference-chip--active { + border-color: #a5b4fc; + background: #eef2ff; + color: #3730a3; +} + +button.image-canvas-editor__reference-chip--active:hover:not(:disabled) { + border-color: #a5b4fc; + background: #e0e7ff; +} + .image-canvas-editor__publication-composer { grid-template-rows: auto auto auto; } diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 7e003f11f..ecde35534 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -205,6 +205,7 @@ export type EditorAssetGenerationInputs = { | 'scene.generate' | 'spec.generate' | 'character.generate' + | 'character-sheet.generate' | 'icon.generate' | 'ui-design.generate' | 'publication.generate' @@ -329,13 +330,19 @@ export type EditorImageGenerationInput = { prompt: string; size?: string; kind?: - 'spec' | 'character' | 'quick-edit' | 'ui-design' | 'publication-material'; + | 'spec' + | 'character' + | 'character-sheet' + | 'quick-edit' + | 'ui-design' + | 'publication-material'; model?: string; screenColor?: string; segModel?: string; style?: EditorImageGenerationStyle; aspectRatio?: string; imageSize?: string; + characterSheetViews?: 4 | 8; referenceImageSrcs?: string[]; projectId?: string | null; assetKind?: string | null; @@ -1194,6 +1201,9 @@ export async function generateEditorImage(input: EditorImageGenerationInput) { ...(input.style ? { style: input.style } : {}), ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), ...(input.imageSize ? { imageSize: input.imageSize } : {}), + ...(input.characterSheetViews + ? { characterSheetViews: input.characterSheetViews } + : {}), ...(input.referenceImageSrcs?.length ? { referenceImageSrcs: input.referenceImageSrcs } : {}),