角色多方向图生成

This commit is contained in:
2026-08-12 18:14:25 +08:00
parent 1575afc92c
commit 132bf7db35
16 changed files with 727 additions and 26 deletions
@@ -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 字段)。
- 无需动拆分逻辑、动画逻辑、存储层。
@@ -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()),
@@ -350,6 +350,9 @@ pub struct EditorImageGenerationRequest {
pub(crate) seg_model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
/// 角色多方向图集的视角数:4 或 8;仅 kind=character-sheet 时生效,缺省 4。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) character_sheet_views: Option<u32>,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) project_id: Option<String>,
pub(crate) asset_kind: Option<String>,
@@ -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!(
@@ -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<Value>,
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<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -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 {
@@ -90,6 +90,12 @@ function CharacterGenerationHarness({
<output aria-label="当前角色设定">{dialog.prompt}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前多方向角色图">
{String(dialog.characterSheetEnabled === true)}
</output>
<output aria-label="当前多方向图视角数">
{dialog.characterSheetViewSet ?? '4'}
</output>
<output aria-label="选择角色规范">{String(isPickingSpec)}</output>
<output aria-label="选择常规参考图">{String(isPickingReference)}</output>
</div>
@@ -122,6 +128,64 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
);
});
it('toggles the multi-direction character sheet option', () => {
render(
<CharacterGenerationHarness
initialDialog={createDialog({ characterSheetEnabled: false })}
/>,
);
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(
<CharacterGenerationHarness
initialDialog={createDialog({ characterSheetEnabled: false })}
/>,
);
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(
<CharacterGenerationHarness
initialDialog={createDialog({
characterSheetEnabled: true,
characterSheetViewSet: '4',
})}
/>,
);
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(
@@ -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<HTMLButtonElement | null>(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 (
<div
@@ -216,6 +240,30 @@ export function ImageCanvasCharacterGenerationComposerView({
isAdd
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
/>
<ImageCanvasReferenceSlot
tone="character-sheet"
icon={<LayoutGrid className="h-4 w-4" aria-hidden="true" />}
label="多方向图"
ariaLabel="多方向角色图"
title={
dialog.characterSheetEnabled === true
? '多方向角色图(开启)'
: '多方向角色图'
}
disabled={dialog.status === 'generating'}
pressed={dialog.characterSheetEnabled === true}
onClick={() =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
characterSheetEnabled:
currentDialog.characterSheetEnabled !== true,
}
: currentDialog,
)
}
/>
{isCharacterReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
@@ -292,8 +340,12 @@ export function ImageCanvasCharacterGenerationComposerView({
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel="生成"
submitAriaLabel="生成"
submitLabel={
dialog.characterSheetEnabled === true ? '生成多方向图集' : '生成'
}
submitAriaLabel={
dialog.characterSheetEnabled === true ? '生成多方向图集' : '生成'
}
onSubmitRequest={() => {
if (dialog.status !== 'generating') {
onSubmit(dialog);
@@ -301,6 +353,62 @@ export function ImageCanvasCharacterGenerationComposerView({
}}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
styleControl={
dialog.characterSheetEnabled === true ? (
<div className="image-canvas-editor__option-popover-anchor">
<PlatformInlineOptionButton
ref={viewSetButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--viewset"
aria-label={`多方向图视角 ${selectedViewSetLabel}`}
aria-expanded={isViewSetMenuOpen}
disabled={dialog.status === 'generating'}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsViewSetMenuOpen((open) => !open)}
>
{selectedViewSetLabel}
</PlatformInlineOptionButton>
{isViewSetMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
label="多方向图视角数"
placement="top-start"
style={buildPortalMenuStyle(
viewSetButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-sections">
<div className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
</span>
<div className="image-canvas-editor__option-popover-items">
{(['4', '8'] as const).map((value) => (
<button
key={value}
type="button"
className="image-canvas-editor__option-popover-choice"
aria-pressed={
(dialog.characterSheetViewSet ?? '4') ===
value
}
onClick={() => setViewSet(value)}
>
{value === '4' ? '四方向' : '八方向'}
</button>
))}
</div>
</div>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : undefined
}
/>
</div>
</div>
@@ -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;
@@ -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({
@@ -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({
@@ -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<CanvasGenerationAction>([
'scene.generate',
'spec.generate',
'character.generate',
'character-sheet.generate',
'icon.generate',
'ui-design.generate',
'publication.generate',
@@ -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,
@@ -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: {
@@ -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,
};
@@ -11,7 +11,8 @@ type ImageCanvasReferenceSlotTone =
| 'character'
| 'quick-edit'
| 'video'
| 'audio';
| 'audio'
| 'character-sheet';
type ImageCanvasReferenceSlotProps = {
buttonRef?: RefObject<HTMLButtonElement | null>;
@@ -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 = (
<>
<span className="image-canvas-editor__reference-chip-icon">
{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}
>
+22
View File
@@ -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;
}
@@ -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 }
: {}),