角色多方向图生成
This commit is contained in:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
Reference in New Issue
Block a user