实现图片画布游戏场景生成链路

新增首页与画布工具栏的游戏场景入口和专用生成表单
新增结构化场景请求契约与后端场景提示词组装
复用图片生成任务的计费、队列、资源入库和画布回写
接入多参考图、画幅选项、预设画风及自定义画风
补充场景链路技术方案和前后端定向测试
This commit is contained in:
2026-08-05 10:59:10 +08:00
parent e6b3199490
commit 221b4aa300
47 changed files with 1932 additions and 54 deletions
@@ -0,0 +1,154 @@
# 图片画布游戏场景生成链路
更新时间:`2026-08-04`
## 1. 目标
在现役图片画布中补齐单张、静态、非分层游戏环境背景的专用生成链路:
```text
首页游戏场景 / 画布底部游戏场景
-> 场景专用生成表单
-> 后端确定性组装场景 Prompt
-> 现有 editor_image_generation 队列与 Worker
-> 现有计费、失败、资源持久化和 canvasCompletion
```
本期不是 Agent 工作流,不新增场景 Agent、场景 Worker、场景任务表或场景专属计费系统。
## 2. 输入和默认值
用户输入:
- 必填画面内容。
- 视觉风格:日系动画、清透水彩、平面几何、定格模型、自定义。
- 自定义风格下必填自定义画风。
- 可选用户参考图,沿用普通图片生成的参考图处理能力。
- 图片比例、清晰度和图片模型。
默认值:
- 图片比例:`16:9`。
- 清晰度:`1K`。
- 模型:`gemini-3.1-flash-image-preview`,产品展示名沿用现有画布。
- 视觉风格:日系动画。
- 参考图:无。
`model`、`aspectRatio`、`imageSize` 继续使用现有编辑器的字符串契约、模型选项、尺寸选项和后端标准化函数,不维护场景专属枚举列表。
## 3. 前端边界
前端只保存和提交结构化场景意图,不保存或拼接完整场景 Prompt。
正式场景状态使用 `GenerateDialogState.mode = "scene"`,不沿用前端壳中的 `generatorVariant = "game-scene"` Demo 标识。
交接壳复用范围:
- 画风选择控件、交互和局部样式。
- 自定义画风条件输入框。
- 底部游戏场景按钮。
- 四张固定画风预览图。
不复用:
- Demo `App.tsx`。
- `setTimeout` 模拟生成。
- FileReader Data URL 正式请求。
- 复制出的整套画布和缩减版类型。
四张固定图片只用于前端预览,不进入 `generationReferences`,不随生成请求提交。保留原分辨率,只做轻量无损压缩。
## 4. API 契约
主站新增:
```text
POST /api/editor/scenes/generations
```
请求字段:
```text
sceneContent
stylePreset
customStyle?
model?
aspectRatio?
imageSize?
referenceImageSrcs?
projectId?
assetFolderId?
assetLabel?
canvasCompletion?
```
请求不接受前端组装后的完整 `prompt`。本期不修改 `/api/external/v1`。
后端对模型、比例和清晰度先沿用 `normalize_editor_generation_options` 标准化,再使用标准化比例决定画幅描述和入队价格。
## 5. Prompt
场景 Prompt 在 `api-server` Rust 代码中维护,沿用现有后端 Prompt 常量和 builder 模式,不拆分为运行时文本模板文件,不调用 LLM 润色、扩写或改写。
提示词来源为产品提供的 `scene_prompt.md`:
- 删除文档章节编号和 `[图片]` 占位。
- 保留场景结构约束和四套完整预设风格正文。
- 自定义模式只使用用户自定义画风,不附加预设风格正文。
画幅方向按标准化比例映射:
- `16:9`、`4:3`、`3:2`:横幅。
- `1:1`:方形。
- `9:16`、`2:3`:竖幅。
## 6. 队列和计费
场景请求在后端组装完整 Prompt 后,转换成现有 `EditorImageGenerationRequest`:
```text
kind = scene
assetKind = scene
prompt = 后端完整 Prompt
```
随后复用 `enqueue_editor_image_generation_for_owner`,队列类型继续是 `editor_image_generation`,Worker 继续执行 `generate_editor_image_for_owner`。
计费规则:
- 前端展示价继续读取 `/api/editor/generation-pricing`。
- 入队时按标准化后的模型和清晰度由后端计算并固化 `external_generation_job.price_mud_points`。
- Worker 按入队价格扣费,不能在场景接口增加第二层扣费。
- `scene` 明确按普通图片模型档位计价。
- Provider 失败、执行取消和 lease 耗尽沿用现有扣退费语义。
- Provider 成功后的持久化或画布写回失败语义不在本期调整。
## 7. 数据落点
不修改 SpacetimeDB schema,继续复用:
- `external_generation_job`。
- `editor_canvas_generation_dialog`。
- `editor_project_resource`。
- `editor_asset`。
- `editor_canvas_layer`。
- `asset_object` / `asset_entity_binding`。
- 现有钱包流水和 `asset_operation_wallet_settlement`。
字段语义:
- `prompt`:后端最终提交给图片 Provider 的完整场景 Prompt。
- `actual_prompt`:Provider 返回的 actual/revised Prompt。
- `asset_kind`:`scene`。
- `generation_inputs_json`:画面内容、视觉风格、自定义画风和用户参考图引用。
## 8. 验收
- 首页和底部按钮进入同一个场景生成器。
- 默认值为 `16:9 / 1K / Banana / 日系动画 / 无参考图`。
- 预设顺序和自定义条件输入正确。
- 预览图不进入请求参考图。
- 前端请求中不存在完整场景 Prompt。
- 后端正确组装四种预设、一个自定义风格和三类画幅。
- 展示价、入队价、实际扣费和资源成本一致。
- 任务复用现有等待、失败、轮询、资源入库和画布添加逻辑。
@@ -0,0 +1,115 @@
export const EDITOR_SCENE_STYLE_PRESETS = {
ANIME: {
value: 'anime',
label: '日系动画',
description: '细腻光影、动画感构图',
},
WATERCOLOR: {
value: 'watercolor',
label: '清透水彩',
description: '通透纸感、柔和自然晕染',
},
FLAT: {
value: 'flat',
label: '平面几何',
description: '清晰色块、简化形体与轮廓',
},
STOP_MOTION: {
value: 'stop-motion',
label: '定格模型',
description: '实体模型质感、微缩布景光影',
},
CUSTOM: {
value: 'custom',
label: '自定义',
description: '填写具体画风',
},
} as const;
type EditorSceneStylePresetDefinition =
(typeof EDITOR_SCENE_STYLE_PRESETS)[keyof typeof EDITOR_SCENE_STYLE_PRESETS];
export type EditorSceneStylePreset = EditorSceneStylePresetDefinition['value'];
export const EDITOR_SCENE_STYLE_PRESET_OPTIONS = Object.values(
EDITOR_SCENE_STYLE_PRESETS,
);
export const DEFAULT_EDITOR_SCENE_STYLE_PRESET =
EDITOR_SCENE_STYLE_PRESETS.ANIME.value;
export const CUSTOM_EDITOR_SCENE_STYLE_PRESET =
EDITOR_SCENE_STYLE_PRESETS.CUSTOM.value;
export function isEditorSceneStylePreset(
value: unknown,
): value is EditorSceneStylePreset {
return EDITOR_SCENE_STYLE_PRESET_OPTIONS.some(
(preset) => preset.value === value,
);
}
export function getEditorSceneStylePresetLabel(
value: EditorSceneStylePreset,
) {
return (
EDITOR_SCENE_STYLE_PRESET_OPTIONS.find(
(preset) => preset.value === value,
)?.label ?? EDITOR_SCENE_STYLE_PRESETS.ANIME.label
);
}
export function resolveEditorSceneStylePresetByLabel(
label: string | null | undefined,
): EditorSceneStylePreset {
return (
EDITOR_SCENE_STYLE_PRESET_OPTIONS.find(
(preset) => preset.label === label,
)?.value ?? DEFAULT_EDITOR_SCENE_STYLE_PRESET
);
}
export type EditorSceneGenerationPlaceholder = {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
};
export type EditorSceneGenerationCompletion = {
dialogId?: string;
title: string;
placeholder: EditorSceneGenerationPlaceholder;
};
export type EditorSceneGenerationInputField = {
title: string;
value: string;
};
export type EditorSceneGenerationInputReference = {
title: string;
label: string;
refType: 'project-resource' | 'asset';
refId: string;
};
export type EditorSceneGenerationInputs = {
fields: EditorSceneGenerationInputField[];
references: EditorSceneGenerationInputReference[];
};
export type EditorSceneGenerationRequest = {
sceneContent: string;
stylePreset: EditorSceneStylePreset;
customStyle?: string | null;
model?: string;
aspectRatio?: string;
imageSize?: string;
referenceImageSrcs?: string[];
projectId?: string | null;
generationInputs?: EditorSceneGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
canvasCompletion?: EditorSceneGenerationCompletion | null;
};
+1
View File
@@ -5,6 +5,7 @@ export type * from './contracts/creationAgentDocumentInput';
export type * from './contracts/creationAudio';
export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent';
export type * from './contracts/editorScene';
export * from './contracts/edutainmentBabyDrawing';
export * from './contracts/edutainmentBabyObject';
export * from './contracts/externalGeneration';
@@ -137,7 +137,7 @@ impl EditorGenerationPricingConfig {
}
match kind.map(str::trim) {
Some("spec") => self.spec_model_mud_points(model),
Some("character" | "icon" | "ui-design" | "publication-material") => {
Some("character" | "icon" | "ui-design" | "publication-material" | "scene") => {
self.image_model_mud_points(model, image_size)
}
_ => self.image_model_mud_points(model, image_size),
@@ -748,6 +748,14 @@ mod tests {
),
12
);
assert_eq!(
editor_image_generation_mud_points(
Some("scene"),
Some("gemini-3.1-flash-image-preview"),
Some("1K")
),
12
);
assert_eq!(
editor_image_generation_mud_points(Some("ui-design"), Some("gpt-image-2"), Some("1K")),
3
@@ -31,7 +31,7 @@ use sha2::{Digest as _, Sha256};
use shared_contracts::assets::{
EDITOR_ASSET_LABEL_MAX_CHARS,
EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest,
EditorCanvasGenerationPlaceholderPayload,
EditorCanvasGenerationPlaceholderPayload, EditorSceneGenerateRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::editor_project::{
@@ -70,6 +70,7 @@ use crate::{
editor_green_screen_character_prompt_clause, editor_ui_design_asset_extraction_prompt,
remove_editor_generated_green_screen_background,
},
editor_scene_prompt::build_editor_scene_prompt,
editor_screen_background_decision::{
EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput,
EditorScreenBackgroundDecisionKind, resolve_editor_screen_background_color,
@@ -178,6 +179,7 @@ pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false;
const EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW: Duration = Duration::from_secs(2);
const EDITOR_BGFILTER_FLAT_LOCAL_RESERVE: Duration = Duration::from_secs(7);
const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material";
const EDITOR_SCENE_IMAGE_ASSET_KIND: &str = "editor_scene_image";
const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image";
#[derive(Debug, Deserialize)]
@@ -1586,6 +1588,116 @@ pub async fn toggle_editor_showcase_asset_like(
))
}
fn editor_scene_style_label(style_preset: &str) -> &'static str {
match style_preset.trim() {
"anime" => "日系动画",
"watercolor" => "清透水彩",
"flat" => "平面几何",
"stop-motion" => "定格模型",
"custom" => "自定义",
_ => "未知",
}
}
fn build_editor_scene_generation_inputs(
payload: &EditorSceneGenerateRequest,
generation_options: &EditorGenerationOptions,
) -> Value {
let references = payload
.generation_inputs
.as_ref()
.and_then(|value| value.get("references"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut fields = vec![
json!({ "title": "画面内容", "value": payload.scene_content.trim() }),
json!({ "title": "视觉风格", "value": editor_scene_style_label(payload.style_preset.as_str()) }),
json!({ "title": "图片比例", "value": generation_options.aspect_ratio }),
json!({ "title": "清晰度", "value": generation_options.image_size }),
json!({ "title": "生成模型", "value": generation_options.model }),
];
if payload.style_preset.trim() == "custom" {
if let Some(custom_style) = payload
.custom_style
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
fields.push(json!({ "title": "自定义画风", "value": custom_style }));
}
}
json!({ "fields": fields, "references": references })
}
pub async fn generate_editor_scene(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
let generation_options = normalize_editor_generation_options(
payload
.model
.as_deref()
.or(Some(EDITOR_IMAGE_MODEL_NANOBANANA2)),
payload.aspect_ratio.as_deref().or(Some("16:9")),
payload.image_size.as_deref().or(Some("1K")),
);
let prompt = build_editor_scene_prompt(
payload.scene_content.as_str(),
payload.style_preset.as_str(),
payload.custom_style.as_deref(),
generation_options.aspect_ratio,
)
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-scene-generation",
"message": message,
}))
})?;
let generation_inputs = build_editor_scene_generation_inputs(&payload, &generation_options);
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
let image_payload = EditorImageGenerationRequest {
prompt,
size: None,
kind: Some("scene".to_string()),
style: None,
model: Some(generation_options.model.to_string()),
screen_color: None,
seg_model: None,
aspect_ratio: Some(generation_options.aspect_ratio.to_string()),
image_size: Some(generation_options.image_size.to_string()),
reference_image_srcs: (!payload.reference_image_srcs.is_empty())
.then_some(payload.reference_image_srcs),
project_id: payload.project_id,
asset_kind: Some("scene".to_string()),
generation_inputs: Some(generation_inputs),
asset_folder_id: payload.asset_folder_id,
asset_label: payload.asset_label.or_else(|| Some("游戏场景".to_string())),
source_resource_id: None,
canvas_completion: payload.canvas_completion,
};
if !state.config.external_generation_mode.is_inline() {
let queue_job = enqueue_editor_image_generation_for_owner(
&state,
&request_context,
&caller,
image_payload,
None,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
generate_editor_image_for_owner(&state, &request_context, caller, image_payload).await
}
pub async fn generate_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1746,6 +1858,7 @@ pub(crate) async fn generate_editor_image_for_owner(
Some("quick-edit") => "图片画布快速编辑图片",
Some("ui-design") => "图片画布生成UI设计图",
Some("publication-material") => "图片画布生成宣发素材",
Some("scene") => "图片画布生成游戏场景",
_ => "图片画布生成图片",
};
let reference_limit = if matches!(normalized_kind, Some("quick-edit")) {
@@ -2295,6 +2408,7 @@ fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) ->
Some("quick-edit") => EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND,
Some("ui-design") => EDITOR_UI_DESIGN_IMAGE_ASSET_KIND,
Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
Some("scene") => EDITOR_SCENE_IMAGE_ASSET_KIND,
_ => EDITOR_GENERATED_IMAGE_ASSET_KIND,
}
}
@@ -2400,6 +2514,11 @@ fn editor_generated_image_storage_profile(
path_kind: "publication-materials",
slot: "publication-material",
},
Some("scene") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_SCENE_IMAGE_ASSET_KIND,
path_kind: "scene-images",
slot: "scene",
},
_ => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_GENERATED_IMAGE_ASSET_KIND,
path_kind: "generated-images",
@@ -14848,4 +14967,16 @@ mod tests {
"function body starting with {start} should contain {snippet} exactly {expected_count} times"
);
}
#[test]
fn scene_generation_uses_scene_billing_and_storage_profiles() {
assert_eq!(
editor_image_generation_billing_asset_kind(Some("scene")),
EDITOR_SCENE_IMAGE_ASSET_KIND,
);
let profile = editor_generated_image_storage_profile(Some("scene"));
assert_eq!(profile.asset_kind, EDITOR_SCENE_IMAGE_ASSET_KIND);
assert_eq!(profile.path_kind, "scene-images");
assert_eq!(profile.slot, "scene");
}
}
@@ -0,0 +1,242 @@
use std::borrow::Cow;
const STYLE_ANIME: &str = "anime";
const STYLE_WATERCOLOR: &str = "watercolor";
const STYLE_FLAT: &str = "flat";
const STYLE_STOP_MOTION: &str = "stop-motion";
const STYLE_CUSTOM: &str = "custom";
const EDITOR_SCENE_STRUCTURE_PROMPT: &str = r###"生成一张静态、单幅、单层合成完成的叙事场景背景,可直接用于视觉小说、对话驱动型游戏、故事事件、章节画面或环境展示。
画面内容:
{{SCENE_CONTENT}}
视觉风格:
{{STYLE_PROMPT}}
画面结构与构图要求:
- 输出一张连续、完整、铺满画面的环境背景,画幅方向与用户所选图片比例 {{ASPECT_RATIO}} 一致({{FRAME_ORIENTATION}});不拆分图层,不制作分屏、拼贴或多格画面。
- 使用稳定的固定视角,以自然平视视角或最适合该地点的建立镜头呈现场景;避免俯视玩法视角、等距视角、鱼眼、夸张广角和倾斜镜头。
- 建立清楚可读的前景、中景和远景关系。单层是指最终输出为一张合成图,不是取消场景的空间纵深。
- 环境必须完整、自然并具有明确的场所特征;画面即使不叠加人物、文字和界面,也应当能够独立成立。
- 通过建筑、家具、植物、道路和环境道具组织视线,形成一个明确但不过度突出的主要环境锚点。
- 主要环境锚点放在中景或画面中上部,不要把唯一的视觉焦点放在画面下四分之一区域。
- 中央及下方区域保持适度安静,降低过密的小型细节、强烈高光和尖锐对比,以便后续覆盖角色立绘、对话框、标题或事件文字。
- “保持安静”只能通过控制细节密度和视觉对比实现,不得制造空盒子、纯色空洞、角色轮廓形缺口或明显的界面占位区。
- 左、中、右区域均不应承载无法被遮挡的唯一叙事信息,保证角色立绘可以在不同位置覆盖画面。
- 前景道具可以辅助建立空间和叙事,但不得大面积遮挡主要环境,不使用贴近镜头的巨大物体封住画面。
- 保持水平和垂直结构稳定,透视关系清楚;除非画面内容明确要求,不制造剧烈运动、爆炸、战斗或瞬间动作。
资产限制:
- 仅生成环境背景。
- 除非画面内容中明确要求,否则不出现人物、人形角色、角色立绘、人群或显著生物主体。
- 允许出现少量动物,但动物只能作为次要环境细节,不能成为画面主角。
- 不出现对话框、用户界面、字幕、标题、说明文字、边框、品牌标志、水印或其他可读文字。
- 黑板、海报、画板、标牌和电子屏幕应为空白,或仅使用简化且不可辨认的图形符号。
- 不出现分屏、拼贴、多格画面、角色表、资产表、设定图、平面图、瓦片地图、等距地图或俯视玩法战场。
- 不把按钮、槽位、交互标记、任务标记、导航信息、碰撞体或其他玩法指示烘烤进图片。
- 输出一张边缘完整、完成度均匀、可直接用于游戏的精修环境背景图。"###;
const EDITOR_SCENE_ANIME_PROMPT: &str = r###"视觉风格:
将“日系动画背景美术”作为锁定的视觉风格。无论画面内容如何变化,以下视觉规律都必须清楚可见。
风格方向:
采用成熟日系二维动画背景美术常见的精修数字绘景体系。最终图像应首先被识别为一张手绘完成的二维栅格环境绘景,其次才被识别为一个具有可信空间和材质的真实地点。
整体使用“选择性线稿+不透明数字上色+局部柔和绘制”的线面结合方式,不模拟实拍照片,也不把整幅环境处理成角色插画式的硬边赛璐璐画面。
必须保持的视觉锚点:
- 空间结构、建筑比例和物体形体保持可信;表面纹理和微小结构经过动画化归纳。真实感主要用于比例、空间和光照一致性,不用于照片级微观材质模拟。
- 将复杂环境概括为大型轮廓、中型结构和少量成组细节。先保证物体外形和色块关系清楚,再使用有限笔触补充表面信息,避免逐项描摹所有纹理。
- 使用具有三级强弱关系的选择性线稿。主要外轮廓和重要结构转折使用细而稳定的有色线;次级接缝使用更细、更浅的线;自然物、柔软表面和较远层次允许轮廓断开、减弱或融入色面。
- 线条颜色从物体固有色中取得较深、较灰的色彩,而不是统一覆盖纯黑描边。近处结构线可以清楚,远处线条必须随色彩与空间自然退去。
- 硬质人造物以清楚边缘、稳定平面和简化高光表现;植物、云层、泥土及其他自然表面使用成组轮廓、簇状笔触和局部虚实边缘表现,不逐片勾勒所有叶片或颗粒。
- 使用不透明数字色块建立基础固有色,再以少量半透明罩色、柔边笔刷和短促实体笔触完成色彩。笔触应被组织在物体形体内部,不形成随意涂抹或厚重油画堆积。
- 固有色保持清楚、明净而经过协调。色彩可以随时间、天气和地点改变,但始终保持明确的色彩主次,不让所有颜色具有相同饱和度和视觉重量。
- 阴影使用带有环境色的色相偏移,而不是在固有色上直接叠加黑色或中性灰。暗部保留可辨认的局部颜色,最深色彩只用于少量接触处和结构缝隙。
- 主要受光区、中间调和阴影区形成两到四个清楚的大明度组。投影与明确结构转折可以使用较硬边缘;环境过渡、反射光和远处色彩使用柔和边缘,避免全画面只有硬边赛璐璐阴影或只有模糊渐变。
- 高光保持克制,只在玻璃、金属、湿润表面或强受光边缘上出现窄而明确的亮部。普通表面维持哑光数字绘景质感,不使用统一塑料高光。
- 空间退远主要通过降低对比度、减弱线稿、压缩细节和使色彩趋向环境色完成,不依靠摄影景深、高斯模糊或大面积雾化遮挡。
- 最终表面允许保留轻微的实体数字笔刷痕迹和非常细的数字笔刷颗粒,但整体必须整洁、稳定、完成度均匀。大形准确,局部笔触可见,缩小观看时仍然具有清楚的日系二维动画环境识别度。
视觉禁用:
避免照片写实、照片描摹和照片拼贴;避免真实PBR材质、光滑三维渲染和游戏引擎截图;避免全场景统一粗黑漫画轮廓、角色式硬边赛璐璐阴影和过度平面卡通化;避免油画厚涂、概念设计式狂乱笔触、透明水彩渗化、水粉纸面和明显传统纸张纹理;避免绝对平滑的矢量色块;避免橙青电影调色、HDR、压死黑位、过量泛光、镜头光晕、色差、散景和浅景深;避免用高密度照片纹理、随机噪点或过度锐化制造虚假的精细度。
最终效果应当是:空间可信、形体经过动画化归纳、细有色线与手绘色面自然融合、色彩明净但不廉价、光影具有设计感而不电影写实的精修日系动画环境背景美术。"###;
const EDITOR_SCENE_WATERCOLOR_PROMPT: &str = r###"视觉风格:
将“透明水彩环境绘景”作为锁定的视觉风格。无论画面内容如何变化,以下视觉规律都必须清楚可见。
风格方向:
使用真实冷压棉浆水彩纸、清水与透明水彩颜料完成精修二维环境绘景。最终图像必须首先被识别为一张在实体水彩纸上逐层绘制的透明水彩作品,而不是普通数字插画叠加纸纹或水渍滤镜。
采用“透明罩染、纸白发光、色彩叠加、边缘随水分变化”的绘制体系。画面可以保留完整、丰富的固有色,但所有色彩都必须具有透明度,底层颜色和纸张本白能够透过上层颜料继续参与最终成像。
必须保持的视觉锚点:
- 使用自然白或轻微暖白的冷压棉浆水彩纸。纸面保留细密凹凸、棉纤维和轻微纸纹,但不得处理成明显宣纸、旧纸、棕黄色仿古纸或粗糙画布。
- 主要亮部直接来自未上色的纸张本白。不得使用大面积不透明白色颜料或数字纯白覆盖制造高光。
- 保持环境结构、建筑比例和物体形体可信,同时将复杂表面归纳为大型透明色面、中型罩染层和少量关键笔触。避免逐项描摹所有微小纹理。
- 不使用统一黑色墨线包围所有物体。主要形体通过色彩边界、明度变化和冷暖关系建立;必要结构线只能使用较浅的中性色或局部深色水彩,并自然融入周围色面。
- 使用透明与半透明水色逐层罩染。底层色彩必须能够透过上层颜料显现,通过色彩叠加产生新的色彩,而不是使用不透明数字色块直接覆盖。
- 保留画面内容中主要物体的固有色。根据场景使用五至八组协调色彩,使蓝色、黄色、红色、绿色、紫色及其他必要颜色清楚可辨;不得默认压缩成黑白、灰褐或统一低饱和旧画色彩。
- 色彩整体保持清透明亮、主次明确。大面积环境色可以柔和,中型物体保持可读,少量关键物体可以拥有较高饱和度,但不得使全画面所有颜色同等鲜艳。
- 阴影采用透明的色彩叠染,通过冷暖偏移和互补色彩形成,不直接在固有色上叠加黑色或不透明灰色。暗部仍应保留底层色彩和纸张反光。
- 清楚保留真实水分运动的证据,包括湿碰湿扩散、柔和渗化、水渍边、回流、花边、颜料颗粒沉积和局部固有色不均。水迹必须服从物体形体和明暗关系,不得随机铺满画面。
- 同一物体允许硬边、软边与消失边并存。受控干燥区域形成清楚边缘;含水区域自然扩散;远处或受光边缘可以融入纸白。
- 穿插少量干笔、擦笔、断续笔触和纸纹露底,表现木材、草地、墙面和其他表面,但不得形成油画厚涂、粉质覆盖或数码纹理喷涂。
- 光影通过纸白、透明罩染、冷暖色彩和边缘虚实共同表现。主要受光区、中间调和阴影区形成清楚层级,同时保持水彩色彩的轻盈与通透。
- 空间退远通过增加清水、降低颜色浓度、减少笔触、减弱边缘和降低对比完成,不依靠摄影景深、高斯模糊、均匀灰雾或电影镜头效果。
- 最终画面必须结构完整、色彩充分、细节经过取舍且完成度均匀。缩小观看时应当首先是一张清楚可读的完整场景,放大后才能看到纸纹、罩染、水渍边和颜料颗粒。
视觉禁用:
避免宣纸水墨、书写性墨线、飞白枯笔和大面积纯黑墨块主导画面;避免默认生成黑白水彩、灰褐速写或统一棕黄色旅行手账;避免不透明水粉、蛋彩、丙烯、油画厚涂和粉质覆盖;避免硬边赛璐璐阴影、统一黑色漫画描边、平滑矢量色块和数字渐变。
避免照片描摹、照片拼贴、真实PBR材质、三维渲染和游戏引擎截图;避免HDR、塑料高光、镜头光晕、散景、景深、过度锐化和电影调色;避免仅在普通数字插画上叠加纸纹、水渍、颗粒或边缘滤镜;避免随机颜料爆炸、失控泼洒和水迹破坏主要形体。
最终效果应当是:完整可信的环境形体由透明罩染、纸白发光、色彩叠加、湿碰湿扩散、干湿边缘、水渍回流和颜料颗粒共同构成的精修透明水彩环境绘景。"###;
const EDITOR_SCENE_FLAT_PROMPT: &str = r###"视觉风格:
将“平面几何场景美术”作为锁定的视觉风格。无论画面内容如何变化,以下视觉规律都必须清楚可见。
风格方向:
使用高度图形化的二维平面几何语言重新构成完整环境。最终图像必须首先被识别为经过精密形状设计和色彩规划的平面场景插画,而不是照片描摹、手绘媒介作品、低多边形三维或带文字的信息海报。
采用“大型几何形状+有限纯色+离散明暗+形状重叠造空间”的视觉体系。对象依靠轮廓、面积、方向和色彩关系被识别,不依赖真实纹理、复杂反射或细密笔触。
必须保持的视觉锚点:
- 将建筑、植物、地面和物件归纳为清楚的大型与中型几何形状,综合使用直线、折线、圆弧、矩形、梯形和少量受控曲线。减少琐碎结构与微小写实细节。
- 每个主要对象必须拥有一眼可辨的整体轮廓。先建立大的形状关系,再使用少量小形状补充必要接缝和标志性细节,不通过自动描摹产生破碎边缘。
- 形状边界干净、稳定、清楚,基本不使用传统手绘轮廓线。必要分隔线采用与色彩体系一致的有色线,不使用粗黑漫画描边。
- 使用五至八组纯色或近纯色组成统一色板,并保持明确的主色、辅助色和强调色面积关系。禁止无限增加相近色彩造成普通数字绘画效果。
- 每种对象只使用两至三个大的明暗层级。亮面、中间面和阴影面之间使用清楚边界,不使用连续渐变、柔光塑形或照片式色彩变化。
- 阴影自身也是经过设计的平面形状,轮廓简洁并服务于色彩节奏。阴影不包含真实反射、复杂半透明、环境遮蔽噪点或柔软摄影边缘。
- 通过形状重叠、大小变化、色彩冷暖、明度分区和细节递减表达空间。空间必须可读,但不追求真实材质与摄影空气感。
- 植物和云层使用成组的图形轮廓与重复模块表现,不逐片绘制叶片或毛边;硬质对象使用稳定平面、规则转折和简化接缝表现。
- 表面基本不出现纸张、颗粒、刷痕、照片纹理、锈迹或木纹,只允许极轻微且统一的色彩变化避免机械僵硬。
- 细节密度保持低到中等。大型轮廓在缩略尺寸下仍必须清楚,任何小型装饰都不能破坏整体形状识别。
- 最终图像保持全幅统一的二维平面逻辑,不让局部物体突然出现真实三维高光、手绘笔触或照片材质。
视觉禁用:
避免连续渐变、柔焦、透明水色、传统纸纹、实体刷痕和颗粒堆积;避免照片贴图、写实材质、复杂高光、真实反射、环境遮蔽和电影光效;避免可见三角形分面、低多边形三维体积和透视产品渲染。
避免自动描摹照片形成的碎裂轮廓;避免廉价图标、信息图、UI面板、海报排版和企业矢量图库感;避免仅通过减少细节得到空洞色块,而没有经过设计的大中小形状、色彩面积和视觉节奏。
最终效果应当是:完整环境被归纳为清楚几何形状、有限色彩、离散明暗和有秩序的形状重叠,形成高可读、低纹理、色彩关系明确的精修二维平面场景美术。"###;
const EDITOR_SCENE_STOP_MOTION_PROMPT: &str = r###"视觉风格:
将“手工模型定格场景”作为锁定的视觉风格。无论画面内容如何变化,以下视觉规律都必须清楚可见。
风格方向:
使用黏土、橡皮泥、泡沫、木料、布料、涂装纸板、金属丝和混合手工材料搭建真实微缩环境,并以定格动画布景摄影方式呈现。最终图像必须首先被识别为一座实际存在的手工模型布景,而不是电脑三维、照片级真实地点或平面插画。
采用“可触摸实体体积+手工材料证据+轻微制作误差+真实棚拍光影”的视觉体系。所有对象都应像能够被制作者拿起、摆放和逐格拍摄。
必须保持的视觉锚点:
- 建筑、植物、地面和道具全部具有明确实体体积,并由可辨认的手工材料制作。不同材料保持各自真实厚度、硬度和表面粗糙度。
- 造型略带手工不对称,直线、圆角、连接处和重复零件存在细微差异,但整体结构稳定、精修且易于识别。
- 黏土和橡皮泥表面保留轻微指纹、压痕、揉捏纹和工具刻痕;纸板与泡沫保留切口、接缝、涂层和少量露底;布料保留纤维与缝线。
- 连接处允许出现少量胶水痕、金属丝骨架、模具接缝、补土和手工拼装误差。这些痕迹必须克制,不把布景变成破损废品。
- 手工涂色略有不均,边缘可见刷痕、颜色覆盖差和细小磨损。色彩仍保持协调,不使用电脑生成的完美材质贴图。
- 植物、云、水面与柔软物体也必须以实际可制作材料表现,例如纸片、泡沫、纤维、树脂或透明薄膜,不允许退回真实自然照片。
- 使用真实摄影棚式主光、补光和环境反射。光线在模型表面形成自然接触阴影、柔和高光和微小遮挡阴影,证明对象确实占有体积。
- 不同材料的高光宽度和粗糙度有所区别,但整体以哑光、可触摸的手工质感为主,不追求复杂PBR反射。
- 允许轻微微缩摄影感和有限景深,但主要环境层次仍应清楚,不能依靠极端浅景深掩盖材料与结构。
- 细节必须符合可实际制作的尺寸与工艺,不出现无限微观结构、真实城市纹理或计算机程序化噪点。
- 最终画面保留实物摄影与逐格拍摄的可信度,所有区域统一属于同一座手工模型布景。
视觉禁用:
避免光滑电脑三维、完美曲面、程序化材质、照片贴图、复杂PBR反射和产品级塑料渲染;避免真实建筑摄影、真人场景、纯平面剪纸和无体积数字插画。
避免所有表面同一种黏土质感;避免过量指纹、污渍和损坏;避免极端微距景深、电影色差、镜头光晕和过度戏剧化照明;避免仅通过圆润造型和玩具题材冒充定格布景。
最终效果应当是:完整环境由具有真实体积的黏土、纸板、泡沫、木料、布料和混合材料搭建,并通过可见手工痕迹与真实棚拍光影形成可信的精修定格动画场景。"###;
fn editor_scene_frame_orientation(aspect_ratio: &str) -> &'static str {
match aspect_ratio {
"16:9" | "4:3" | "3:2" => "横幅",
"9:16" | "2:3" => "竖幅",
_ => "方形",
}
}
fn resolve_editor_scene_style_prompt<'a>(
style_preset: &str,
custom_style: Option<&'a str>,
) -> Result<Cow<'a, str>, &'static str> {
match style_preset.trim() {
STYLE_ANIME => Ok(Cow::Borrowed(EDITOR_SCENE_ANIME_PROMPT)),
STYLE_WATERCOLOR => Ok(Cow::Borrowed(EDITOR_SCENE_WATERCOLOR_PROMPT)),
STYLE_FLAT => Ok(Cow::Borrowed(EDITOR_SCENE_FLAT_PROMPT)),
STYLE_STOP_MOTION => Ok(Cow::Borrowed(EDITOR_SCENE_STOP_MOTION_PROMPT)),
STYLE_CUSTOM => custom_style
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| Cow::Owned(value.to_string()))
.ok_or("选择自定义画风时必须填写 customStyle"),
_ => Err("stylePreset 仅支持 anime、watercolor、flat、stop-motion 或 custom"),
}
}
pub(crate) fn build_editor_scene_prompt(
scene_content: &str,
style_preset: &str,
custom_style: Option<&str>,
normalized_aspect_ratio: &str,
) -> Result<String, &'static str> {
let scene_content = scene_content.trim();
if scene_content.is_empty() {
return Err("sceneContent 不能为空");
}
let style_prompt = resolve_editor_scene_style_prompt(style_preset, custom_style)?;
Ok(EDITOR_SCENE_STRUCTURE_PROMPT
.replace("{{SCENE_CONTENT}}", scene_content)
.replace("{{STYLE_PROMPT}}", style_prompt.as_ref())
.replace("{{ASPECT_RATIO}}", normalized_aspect_ratio)
.replace(
"{{FRAME_ORIENTATION}}",
editor_scene_frame_orientation(normalized_aspect_ratio),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scene_prompt_uses_normalized_frame_orientation() {
let horizontal = build_editor_scene_prompt("雨夜小镇", STYLE_ANIME, None, "16:9")
.expect("horizontal scene prompt");
let square = build_editor_scene_prompt("雨夜小镇", STYLE_WATERCOLOR, None, "1:1")
.expect("square scene prompt");
let vertical = build_editor_scene_prompt("雨夜小镇", STYLE_FLAT, None, "9:16")
.expect("vertical scene prompt");
assert!(horizontal.contains("图片比例 16:9 一致(横幅)"));
assert!(square.contains("图片比例 1:1 一致(方形)"));
assert!(vertical.contains("图片比例 9:16 一致(竖幅)"));
}
#[test]
fn scene_prompt_uses_all_preset_styles() {
for (preset, expected) in [
(STYLE_ANIME, "日系动画背景美术"),
(STYLE_WATERCOLOR, "透明水彩环境绘景"),
(STYLE_FLAT, "平面几何场景美术"),
(STYLE_STOP_MOTION, "手工模型定格场景"),
] {
let prompt = build_editor_scene_prompt("海边车站", preset, None, "16:9")
.expect("preset scene prompt");
assert!(
prompt.contains(expected),
"missing preset marker: {expected}"
);
}
}
#[test]
fn scene_prompt_uses_custom_style_without_preset_text() {
let prompt = build_editor_scene_prompt(
"安静的海边车站",
STYLE_CUSTOM,
Some("90年代复古像素风"),
"3:2",
)
.expect("custom scene prompt");
assert!(prompt.contains("90年代复古像素风"));
assert!(!prompt.contains("日系动画背景美术"));
}
#[test]
fn scene_prompt_rejects_missing_required_inputs() {
assert!(build_editor_scene_prompt("", STYLE_ANIME, None, "16:9").is_err());
assert!(build_editor_scene_prompt("场景", STYLE_CUSTOM, None, "16:9").is_err());
assert!(build_editor_scene_prompt("场景", "unknown", None, "16:9").is_err());
}
#[test]
fn scene_prompt_source_does_not_include_document_placeholders() {
for prompt in [
EDITOR_SCENE_STRUCTURE_PROMPT,
EDITOR_SCENE_ANIME_PROMPT,
EDITOR_SCENE_WATERCOLOR_PROMPT,
EDITOR_SCENE_FLAT_PROMPT,
EDITOR_SCENE_STOP_MOTION_PROMPT,
] {
assert!(!prompt.contains("[图片]"));
assert!(!prompt.contains("8.1"));
assert!(!prompt.contains("8.2"));
}
}
}
+1
View File
@@ -26,6 +26,7 @@ mod editor_generation_config;
mod editor_generation_queue;
mod editor_green_screen;
mod editor_project;
mod editor_scene_prompt;
mod editor_screen_background_decision;
mod editor_screen_background_filter;
mod error_middleware;
@@ -19,10 +19,10 @@ use crate::{
create_editor_project, create_editor_project_resource, delete_editor_asset,
delete_editor_asset_folder, delete_editor_project, edit_editor_image,
extract_editor_ui_design_assets, generate_editor_icon_spritesheet, generate_editor_image,
get_editor_asset_library, get_editor_generation_pricing, get_editor_project,
list_editor_projects, list_public_editor_project_resources, load_recent_editor_project,
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
split_editor_icon_spritesheet, submit_editor_asset_showcase,
generate_editor_scene, get_editor_asset_library, get_editor_generation_pricing,
get_editor_project, list_editor_projects, list_public_editor_project_resources,
load_recent_editor_project, remove_editor_image_background, rename_editor_project,
save_editor_project_layout, split_editor_icon_spritesheet, submit_editor_asset_showcase,
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
update_editor_project_resource_showcase,
},
@@ -191,6 +191,13 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/editor/scenes/generations",
post(generate_editor_scene).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/images/edits",
post(edit_editor_image).route_layer(middleware::from_fn_with_state(
@@ -368,6 +368,33 @@ pub struct EditorCharacterAnimationGenerateResponse {
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSceneGenerateRequest {
pub scene_content: String,
pub style_preset: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_style: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reference_image_srcs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_inputs: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorVideoGenerateRequest {
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

@@ -342,7 +342,7 @@ describe('CreationLandingView', () => {
['游戏角色', 'character-spec'],
['游戏UI', 'ui-design'],
['游戏音乐', 'background-music'],
['游戏场景', 'image'],
['游戏场景', 'scene'],
['游戏美宣', 'publication-cover'],
])(
'creates a new project from %s with its canvas tool intent',
@@ -58,7 +58,8 @@ type CreationFeatureTool =
| 'ui-design'
| 'background-music'
| 'image'
| 'publication-cover';
| 'publication-cover'
| 'scene';
type CreationFeatureItem = {
title: string;
@@ -110,7 +111,7 @@ const CREATION_FEATURES: CreationFeatureItem[] = [
title: '游戏场景',
description: '轻松生成各类游戏场景素材',
iconSrc: '/creation-home/feature-scene.png',
action: { kind: 'tool', tool: 'image' },
action: { kind: 'tool', tool: 'scene' },
},
{
title: '游戏美宣',
@@ -7,6 +7,7 @@ import {
type SetStateAction,
} from 'react';
import { CUSTOM_EDITOR_SCENE_STYLE_PRESET } from '../../../packages/shared/src/contracts/editorScene';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
@@ -83,6 +84,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
submitLabel?: string;
submitAriaLabel?: string;
submittingStatusLabel?: string;
styleControl?: ReactNode;
};
function resetFailedDialogStatus(dialog: GenerateDialogState) {
@@ -136,6 +138,7 @@ export function ImageCanvasBasicGenerationComposerView({
submitLabel = '生成',
submitAriaLabel = '生成',
submittingStatusLabel = '生成中',
styleControl,
}: ImageCanvasBasicGenerationComposerViewProps) {
const references = dialog.generationReferences ?? [];
const isQuickEdit = dialog.mode === 'quick-edit';
@@ -267,6 +270,28 @@ export function ImageCanvasBasicGenerationComposerView({
)
}
/>
{dialog.mode === 'scene' &&
dialog.sceneStylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET ? (
<PlatformTextField
aria-label="自定义画风"
value={dialog.sceneCustomStyle ?? ''}
disabled={dialog.status === 'generating'}
placeholder="填写自定义画风,例如:90 年代复古像素风"
size="sm"
density="compact"
className="image-canvas-editor__scene-custom-style"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
sceneCustomStyle: event.target.value,
}
: currentDialog,
)
}
/>
) : null}
<div className={finalFooterClassName}>
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
@@ -284,6 +309,7 @@ export function ImageCanvasBasicGenerationComposerView({
submitLabel={resolvedSubmitLabel}
submitAriaLabel={resolvedSubmitAriaLabel}
submitButtonClassName={submitButtonClassName}
styleControl={styleControl}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
@@ -79,6 +79,7 @@ describe('ImageCanvasBottomToolbarView', () => {
['生成图标素材', 'icon'],
['生成UI设计图', 'ui-design'],
['宣发素材', 'publication'],
['游戏场景', 'scene'],
] as const;
for (const [label, tool] of toolExpectations) {
@@ -55,6 +55,7 @@ const canvasTools: Array<{
{ id: 'character', label: '生成角色形象', icon: UserRound },
{ id: 'icon', label: '生成图标素材', icon: Grid2X2 },
{ id: 'ui-design', label: '生成UI设计图', icon: AppWindow },
{ id: 'scene', label: '生成游戏场景', icon: ImageIcon },
{ id: 'publication', label: '宣发素材', icon: Megaphone },
];
@@ -756,6 +756,31 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('restores scene generation dialog fields', () => {
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-scene',
mode: 'scene',
prompt: '海边车站',
status: 'idle',
sceneStylePreset: 'custom',
sceneCustomStyle: '90年代复古像素风',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
}),
).toMatchObject({
id: 'generation-dialog-scene',
mode: 'scene',
prompt: '海边车站',
sceneStylePreset: 'custom',
sceneCustomStyle: '90年代复古像素风',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
});
});
it('defaults restored supported image styles to none and drops them from other modes', () => {
expect(
hydrateCanvasGenerationDialog({
@@ -1,3 +1,4 @@
import { isEditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene';
import type {
EditorAssetLibrarySnapshot,
EditorCharacterAnimationGenerationResult,
@@ -626,6 +627,10 @@ export function hydrateCanvasGenerationDialog(
resourcesById,
currentUserId,
),
sceneStylePreset: isEditorSceneStylePreset(snapshot.sceneStylePreset)
? snapshot.sceneStylePreset
: undefined,
sceneCustomStyle: stringOrUndefined(snapshot.sceneCustomStyle),
imageModel: stringOrUndefined(snapshot.imageModel),
style,
videoModel:
@@ -1204,7 +1209,8 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
value === 'ui-design' ||
value === 'video' ||
value === 'sound-effect' ||
value === 'background-music'
value === 'background-music' ||
value === 'scene'
? value
: null;
}
@@ -1233,6 +1239,7 @@ function isCanvasGenerationDialogMode(
value === 'icon' ||
value === 'publication' ||
value === 'ui-design' ||
value === 'scene' ||
value === 'quick-edit' ||
value === 'character-animation' ||
value === 'video' ||
@@ -1,3 +1,4 @@
import type { EditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene';
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
@@ -25,7 +26,8 @@ export type CanvasAssetKind =
| 'ui-design'
| 'video'
| 'sound-effect'
| 'background-music';
| 'background-music'
| 'scene';
export type CanvasMediaType = 'image' | 'video' | 'audio' | 'image-sequence';
@@ -135,7 +137,8 @@ export type CanvasTool =
| 'character'
| 'icon'
| 'publication'
| 'ui-design';
| 'ui-design'
| 'scene';
export type SidebarPanel = 'assets' | 'layers';
@@ -196,7 +199,8 @@ export type GenerateDialogState = {
| 'character-animation'
| 'video'
| 'audio-sound-effect'
| 'audio-background-music';
| 'audio-background-music'
| 'scene';
prompt: string;
assetLabel?: string;
status: 'idle' | 'generating' | 'failed';
@@ -215,6 +219,8 @@ export type GenerateDialogState = {
publicationGameInfo?: PublicationMaterialsGameInfo;
publicationReferences?: CharacterReferenceImage[];
uiDesignSpecReference?: CharacterReferenceImage | null;
sceneStylePreset?: EditorSceneStylePreset;
sceneCustomStyle?: string;
imageModel?: string;
style?: EditorImageGenerationStyle;
videoModel?: EditorVideoModel;
@@ -533,6 +533,37 @@ describe('ImageCanvasEditorView', () => {
expect(window.location.search).toBe('?projectid=editor-project-music');
});
it('opens the scene startup tool with scene defaults', async () => {
loadEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision({
projectId: 'editor-project-scene',
title: '场景项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-04T00:00:00.000Z',
}),
);
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=editor-project-scene&tool=scene',
);
render(<ImageCanvasEditorView />);
const dialog = await screen.findByRole('dialog', {
name: '游戏场景生成器',
});
expect(
within(dialog).getByRole('button', { name: '游戏场景尺寸 16:9·1K' }),
).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '画风预设 日系动画' }),
).toBeTruthy();
expect(window.location.search).toBe('?projectid=editor-project-scene');
});
it('flushes project persistence before returning from the topbar', async () => {
const onPopState = vi.fn();
window.addEventListener('popstate', onPopState);
@@ -110,7 +110,8 @@ type CanvasStartupTool =
| 'ui-design'
| 'background-music'
| 'image'
| 'publication-cover';
| 'publication-cover'
| 'scene';
const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'character-spec',
@@ -119,6 +120,7 @@ const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'background-music',
'image',
'publication-cover',
'scene',
];
type ImageCanvasEditorViewProps = {
@@ -176,6 +178,10 @@ function openCanvasStartupTool(
generationSurface.openBackgroundMusicGenerationDialog();
return;
}
if (tool === 'scene') {
generationSurface.openSceneGenerationDialog();
return;
}
if (tool === 'publication-cover') {
generationSurface.openPublicationGenerationDialog(
'publication-cover-image',
@@ -161,6 +161,31 @@ describe('ImageCanvasGenerationComposerView', () => {
).toBeTruthy();
});
it('显示游戏场景专用输入和画风控件', () => {
renderComposer({
mode: 'scene',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
sceneStylePreset: 'anime',
sceneCustomStyle: '',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
});
const panel = screen.getByRole('dialog', { name: '游戏场景生成器' });
expect(within(panel).getByRole('textbox', { name: '画面内容' })).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '画风预设 日系动画' }),
).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '游戏场景尺寸 16:9·1K' }),
).toBeTruthy();
expect(within(panel).queryByText('像素艺术')).toBeNull();
});
it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => {
const setGenerateDialog = vi.fn();
renderComposer({
@@ -57,6 +57,7 @@ import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPubli
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { ImageCanvasSceneStyleControl } from './ImageCanvasSceneStyleControl';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -982,7 +983,8 @@ export function ImageCanvasGenerationComposerView({
: null}
{(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'quick-edit') &&
generateDialog?.mode === 'quick-edit' ||
generateDialog?.mode === 'scene') &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasBasicGenerationComposerView
@@ -1003,6 +1005,31 @@ export function ImageCanvasGenerationComposerView({
}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmitImageGeneration}
dialogLabel={
generateDialog.mode === 'scene' ? '游戏场景生成器' : undefined
}
promptLabel={generateDialog.mode === 'scene' ? '画面内容' : undefined}
promptPlaceholder={
generateDialog.mode === 'scene'
? '描述你想生成的游戏场景,例如:雨夜中的欧洲小镇街道……'
: undefined
}
optionLabelPrefix={
generateDialog.mode === 'scene' ? '游戏场景' : undefined
}
referenceButtonLabel={
generateDialog.mode === 'scene' ? '参考图' : undefined
}
styleControl={
generateDialog.mode === 'scene' ? (
<ImageCanvasSceneStyleControl
dialog={generateDialog}
setGenerateDialog={setGenerateDialog}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
) : undefined
}
/>
) : null}

Some files were not shown because too many files have changed in this diff Show More