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 } : {}),