场景生成接入改造配方

新增 scene.generate V2 配方、解码与场景面板恢复
服务端按结构化请求重建权威场景字段和参考图槽位
兼容旧场景画面内容字段并限制非场景改造入口
补充前后端测试与 assetKind 改造接入文档
This commit is contained in:
2026-08-08 12:26:43 +08:00
parent 179c4c36c5
commit 5c0e84c78a
14 changed files with 497 additions and 190 deletions
@@ -6811,3 +6811,11 @@
- V2 水合统一按 `version/action/fields[].id/references[].id` 严格识别,保留有限数字、布尔值与无标签引用;出现 V2 标记但结构无效时不得降级 legacy。完美像素账本复用相同 V2 白名单,外层仍为 version 1,并继续原形接受旧 legacy 请求以维持 exact retry fingerprint。
- 安全边界:站内已迁移队列只保留客户端 references 的安全槽位 `id`,真实 `title/label/refType/refId` 全部按 owner-scoped 记录重建;External API 和直接不可信写入仍删除整段 references。队列幂等比较忽略该冗余展示槽位,但继续严格比较实际媒体来源与其它参数。
- 不改 SpacetimeDB schema、路由或 External OpenAPI;不回填历史记录。
## 2026-08-08 游戏场景接入 V2 改造配方
- 背景:`72f268e0` 新增 `assetKind = scene` 和场景专用生成入口,但场景生成输入仍是依赖中文标题的 legacy 快照,无法通过现役 V2 action allowlist 稳定恢复“改造”。
- 决策:新增稳定 action `scene.generate`,字段 ID 固定为 `prompt/stylePreset/customStyle/model/aspectRatio/imageSize`,参考槽 ID 固定为 `reference`。新产物由服务端按结构化场景请求重建权威 V2 fields,并从真实 `referenceImageSrcs` 生成安全引用槽位,后续继续沿用 owner-scoped provenance 重建;前端 action decoder 恢复场景 composer,并让请求与持久快照共用规范化参数。
- capability 边界:`assetKind` 只表示素材类别,不直接授予“改造”。`scene.generate` 显式进入已知与可改造 action allowlist;V2 上线前的场景仅在 `assetKind === scene` 且 legacy 字段包含“画面内容”时兼容恢复并告警,不把“画面内容”加入全局 legacy 标题路由,其他类别同名字段继续拒绝改造。
- 影响范围:图片画布场景提交、生成输入解码、改造入口、场景 composer 恢复、api-server 场景配方重建与对应前后端测试;不修改 SpacetimeDB schema、migration、bindings、External v1 路由或 OpenAPI。
- 关联文档:`docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
@@ -1,6 +1,6 @@
# 图片画布游戏场景生成链路
更新时间:`2026-08-04`
更新时间:`2026-08-08`
## 1. 目标
@@ -151,7 +151,20 @@ prompt = 后端完整 Prompt
- `prompt`:后端最终提交给图片 Provider 的完整场景 Prompt。
- `actual_prompt`:Provider 返回的 actual/revised Prompt。
- `asset_kind`:`scene`。
- `generation_inputs_json`:画面内容、视觉风格、自定义画风和用户参考图引用。
- `generation_inputs_json`:可恢复场景生成器的 V2 配方;`assetKind = scene` 只表示素材类别,不直接授予“改造”能力。
场景新产物统一保存以下稳定执行契约:
```text
version = 2
action = scene.generate
fields[].id = prompt | stylePreset | customStyle | model | aspectRatio | imageSize
references[].id = reference
```
`title` / `label` 仅用于中文展示,不参与路由或恢复。前端提交快照和再次提交使用同一组规范化后的模型、比例与清晰度;后端忽略客户端提供的执行字段和引用 provenance,按请求顶层结构化场景字段重建 `fields`,并按本次真实 `referenceImageSrcs` 只保留安全的 `references[id="reference"]` 槽位,再由现有 owner-scoped 引用重建链补齐 `refType/refId`。
`scene.generate` 必须同时进入已知 action 与可改造 action allowlist,并由 action 级 decoder 和场景 composer 恢复路径读取。`assetKind = scene` 不能替代上述 capability 判断。`72f268e0` 之后、V2 上线之前产生的场景数据只在 `assetKind === "scene"` 且 legacy `fields` 含展示标题“画面内容”时走既有 legacy 恢复和告警;“画面内容”不得加入全局 legacy 可复用标题列表,其他素材即使带同名字段也不能获得改造入口。
## 8. 验收
@@ -164,3 +177,5 @@ prompt = 后端完整 Prompt
- 展示价、入队价、实际扣费和资源成本一致。
- 任务复用现有等待、失败、轮询、资源入库和画布添加逻辑。
- 场景产物以 `assetKind = scene` 持久化,画布图层右上角显示“场景”标签,不降级为“未知”。
- 新场景产物保存 `scene.generate` V2 配方,点击“改造”后恢复画面内容、风格、自定义风格、模型、比例、清晰度和当前画布中仍存在的参考图,再次提交仍保存规范 V2。
- V2 场景、窄化匹配的历史场景显示“改造”;非场景素材即使 legacy 字段标题为“画面内容”也不得显示“改造”。
@@ -447,7 +447,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 运维入口固定为 `node scripts/spacetime-normalize-editor-character-actions.mjs --database <database> --server-url <url>`;默认全量 dry-run,`--apply` 时每批先 dry-run,再用返回的 batch SHA-256 写入,四个 scope 完成后从头执行零匹配 / 零 blocker 复核。迁移完成后 api-server、后台、前端和外部 helper 只读取正式字段,不再包含 legacy fallback。新建动作资源 / 素材若在 `generationInputs` 提交旧运行字段,或正式帧包含 `frameIndex`,api-server 与 SpacetimeDB storage 均失败关闭;其它素材的任意生成输入不受动作专属门禁影响。
- 普通画布 layer 的持久化和前端响应不包含顶层 `mediaType`;渲染类型只由资源 / 素材 `assetKind` 在前端派生。`generationInputs.references[*].mediaType` 是生成参考输入契约,不属于画布 layer legacy 字段,迁移和响应清洗不得递归删除。
## 新增编辑器 `assetKind` 接入清单(2026-08-03)
## 新增编辑器 `assetKind` 接入清单(2026-08-03,2026-08-08 增补)
新增前先确定稳定字符串、主媒体含义、专属元数据、来源血缘、下载产物、复用规则和公开边界。只修改新类别实际经过的链路,不机械改动全部结构。
@@ -463,6 +463,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
| 后台契约 | `server-rs/crates/shared-contracts/src/admin.rs`、`api-server/src/admin.rs` | 后台素材查询或精选审核需要展示该类别时返回完整正式字段,不能只返回封面或首帧 |
| TypeScript 客户端类型 | `src/services/image-editor/editorProjectClient.ts`、`apps/admin-web/src/api/adminApiTypes.ts` | 接收后端 camelCase 字段,不定义第二套业务真相 |
| 画布 layer / layout 映射 | `src/components/image-editor/` | 新类别能进入画布时贯通资源加载、素材点击 / 拖放、保存恢复、复制、撤销、删除和导出;layout 只保留恢复副本 |
| “改造” capability 与 V2 配方 | `src/components/image-editor/ImageCanvasGenerationInputsModel.ts`、`ImageCanvasGenerationModel.ts`、`ImageCanvasGenerationDialogModel.ts` 及生成提交链 | 新类别接入时必须显式判断是否允许恢复原生成器。可改造的生成类别必须定义稳定 V2 `action`、`fields[].id`、`references[].id`、action 级 decoder、面板恢复路径、改造 allowlist 和往返测试;确定性或不可重放类别即使保存 action 也不得进入改造 allowlist。`assetKind` 本身不能授予 capability;legacy 兼容只能按类别和旧配方特征窄化,并覆盖正反例,禁止把显示标题扩成全局路由键 |
| 素材库与 renderer | `src/components/image-editor/` | 由 `assetKind` 派生图片、视频、音频或序列 renderer,并实现正确缩略图、预览和下载行为 |
| 后台媒体 renderer | `apps/admin-web/src/components/AdminEditorAssetMedia.tsx` | 后台需要预览时复用现有组件;列表只加载最小媒体,弹窗再按需加载完整媒体 |
| 精选 read model 与 renderer | `api-server`、`src/components/creation-home/` | 新类别允许公开时贯通正式字段、卡片、弹窗和损坏数据行为 |
@@ -472,6 +473,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
其它注意事项:
- `assetKind` 是数据库、Rust DTO 和对外 JSON 的唯一媒体类别真相;不要新增或返回并列的 `mediaType`。前端 renderer 可以保留内部派生类型,但不得回写后端。
- `assetKind` 只描述素材类别,不代表生成配方可执行或允许“改造”;新增类别必须单独完成上表的 capability 决策和验收。
- 只是新增分类或 renderer 且现有媒体字段足够时,不改 schema。只有必须跨刷新、复用、审核或公开保留的数据才新增类别专属字段。
- legacy 数据必须先通过有界、可审计、带 dry-run/hash/apply 门禁的数据库迁移收口;迁移后的 api-server、mapper、主站、后台和画布只读取正式字段,不保留运行时 fallback。
@@ -114,7 +114,7 @@
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
- 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。
- 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。
- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。
@@ -2478,34 +2478,24 @@ 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 {
// 场景结构字段由专用请求和服务端标准化结果重建;客户端 generationInputs 既不能
// 覆盖这些执行字段,也不能声明引用 provenance。这里只按实际参考图数量保留安全槽位,
// 后续统一 owner-scoped reference rebuild 会用正式资源 / 素材记录补齐身份。
let references = payload
.generation_inputs
.as_ref()
.and_then(|value| value.get("references"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
.reference_image_srcs
.iter()
.map(|_| json!({ "id": "reference" }))
.collect::<Vec<_>>();
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 }),
json!({ "id": "prompt", "title": "画面内容", "value": payload.scene_content.trim() }),
json!({ "id": "stylePreset", "title": "视觉风格", "value": payload.style_preset.trim() }),
json!({ "id": "model", "title": "模型", "value": generation_options.model }),
json!({ "id": "aspectRatio", "title": "比例", "value": generation_options.aspect_ratio }),
json!({ "id": "imageSize", "title": "尺寸", "value": generation_options.image_size }),
];
if payload.style_preset.trim() == "custom" {
if let Some(custom_style) = payload
@@ -2514,10 +2504,16 @@ fn build_editor_scene_generation_inputs(
.map(str::trim)
.filter(|value| !value.is_empty())
{
fields.push(json!({ "title": "自定义画风", "value": custom_style }));
fields
.push(json!({ "id": "customStyle", "title": "自定义画风", "value": custom_style }));
}
}
json!({ "fields": fields, "references": references })
json!({
"version": 2,
"action": "scene.generate",
"fields": fields,
"references": references,
})
}
fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str {
@@ -22141,11 +22137,12 @@ mod tests {
}
#[test]
fn scene_generation_rebuilds_fields_and_only_keeps_reference_rows() {
fn scene_generation_rebuilds_v2_recipe_and_only_keeps_reference_slots() {
let payload: EditorSceneGenerateRequest = serde_json::from_value(json!({
"sceneContent": " 雨夜中的海边车站 ",
"stylePreset": "custom",
"customStyle": "复古像素风",
"referenceImageSrcs": ["resource-scene-reference"],
"generationInputs": {
"fields": [
{ "title": "伪造字段", "value": "不得透传" }
@@ -22174,14 +22171,24 @@ mod tests {
assert!(!serialized.contains("不得透传"));
assert!(serialized.contains("雨夜中的海边车站"));
assert!(serialized.contains("复古像素风"));
assert!(!serialized.contains("editor-resource-scene-reference"));
assert_eq!(generation_inputs["version"], json!(2));
assert_eq!(generation_inputs["action"], json!("scene.generate"));
assert_eq!(generation_inputs["fields"][0]["id"], json!("prompt"));
assert_eq!(
generation_inputs["fields"][1],
json!({ "id": "stylePreset", "title": "视觉风格", "value": "custom" })
);
assert_eq!(generation_inputs["fields"][2]["id"], json!("model"));
assert_eq!(generation_inputs["fields"][3]["id"], json!("aspectRatio"));
assert_eq!(generation_inputs["fields"][4]["id"], json!("imageSize"));
assert_eq!(
generation_inputs["fields"][5],
json!({ "id": "customStyle", "title": "自定义画风", "value": "复古像素风" })
);
assert_eq!(
generation_inputs["references"],
json!([{
"title": "参考图",
"label": "车站参考",
"refType": "project-resource",
"refId": "editor-resource-scene-reference"
}])
json!([{ "id": "reference" }])
);
}
}
@@ -73,6 +73,7 @@ export type EditorAsset = {
export type CanvasGenerationAction =
| 'image.generate'
| 'scene.generate'
| 'spec.generate'
| 'character.generate'
| 'icon.generate'
@@ -1054,7 +1054,11 @@ describe('ImageCanvasGenerationDialogModel', () => {
version: 2,
action: 'image.generate',
fields: [
{ id: 'model', title: '任意标题', value: IMAGE_MODEL_GPT_IMAGE_2 },
{
id: 'model',
title: '任意标题',
value: IMAGE_MODEL_GPT_IMAGE_2,
},
{ id: 'aspectRatio', title: '任意标题', value: '2:3' },
{ id: 'imageSize', title: '任意标题', value: '2K' },
],
@@ -1082,6 +1086,98 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('restores scene.generate V2 inputs into the scene composer', () => {
const sceneReference = createLayer({
id: 'layer-scene-reference',
resourceId: 'resource-scene-reference',
title: '车站参考',
});
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
id: 'layer-scene-result',
resourceId: 'resource-scene-result',
sourceType: 'generated',
assetKind: 'scene',
generationInputs: {
version: 2,
action: 'scene.generate',
fields: [
{ id: 'prompt', title: '任意标题', value: '雨夜海边车站' },
{ id: 'stylePreset', title: '任意标题', value: 'custom' },
{ id: 'customStyle', title: '任意标题', value: '复古像素风' },
{
id: 'model',
title: '任意标题',
value: IMAGE_MODEL_GPT_IMAGE_2,
},
{ id: 'aspectRatio', title: '任意标题', value: '16:9' },
{ id: 'imageSize', title: '任意标题', value: '2K' },
],
references: [
{
id: 'reference',
title: '任意标题',
refType: 'project-resource',
refId: 'resource-scene-reference',
},
],
},
}),
availableLayers: [sceneReference],
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toMatchObject({
mode: 'scene',
prompt: '雨夜海边车站',
sceneStylePreset: 'custom',
sceneCustomStyle: '复古像素风',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
generationReferences: [
expect.objectContaining({
id: 'canvas-layer-scene-reference',
label: '车站参考',
}),
],
});
});
it('restores the narrowly scoped legacy scene recipe into the scene composer', () => {
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
assetKind: 'scene',
model: IMAGE_MODEL_GPT_IMAGE_2,
originalWidth: 2048,
originalHeight: 1152,
generationInputs: {
fields: [
{ title: '画面内容', value: '雨夜海边车站' },
{ title: '视觉风格', value: '清透水彩' },
],
references: [],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
}),
).toMatchObject({
mode: 'scene',
prompt: '雨夜海边车站',
sceneStylePreset: 'watercolor',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
});
});
it('restores quick-edit references and the persisted image model', () => {
const editSource = createLayer({
id: 'layer-edit-source',
@@ -1442,7 +1538,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
generationReferences: [{ label: '参考图' }],
});
expect(
appendGenerationReference(sceneWithReference, createLayer({ id: 'other' })),
appendGenerationReference(
sceneWithReference,
createLayer({ id: 'other' }),
),
).toMatchObject({
generationReferences: [{ label: '参考图' }, { label: '源图' }],
});
@@ -765,7 +765,7 @@ export function createLayerGenerationDialogDraft({
: draft.prompt || sourceLayer.prompt?.trim() || '',
imageModel: usesNormalizedInputs
? draft.imageModel
: sourceDialog?.imageModel ?? draft.imageModel,
: (sourceDialog?.imageModel ?? draft.imageModel),
status: 'idle',
composerOpen: true,
generatedLayerId: sourceLayer.id,
@@ -933,8 +933,37 @@ function createNormalizedGenerationDialogDraft({
);
}
if (inputs.action === 'scene.generate') {
return placeDraftBesideSourceLayer(
resizeGenerationPlaceholderToImageSelection({
...createSceneGenerationDialogDraft({ canvasSize, viewport }),
prompt,
sourceLayerId: sourceLayer.id,
sceneStylePreset: getNormalizedString(
sourceLayer,
'stylePreset',
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
) as GenerateDialogState['sceneStylePreset'],
sceneCustomStyle: getNormalizedString(sourceLayer, 'customStyle'),
imageModel: getNormalizedString(
sourceLayer,
'model',
DEFAULT_IMAGE_MODEL,
),
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '16:9'),
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
generationReferences: references,
}),
sourceLayer,
);
}
if (inputs.action === 'spec.generate') {
const specTypeValue = getNormalizedString(sourceLayer, 'specType', 'custom');
const specTypeValue = getNormalizedString(
sourceLayer,
'specType',
'custom',
);
const specType: SpecGenerationType =
specTypeValue === 'character' ||
specTypeValue === 'ui' ||
@@ -953,8 +982,11 @@ function createNormalizedGenerationDialogDraft({
customPrompt: getNormalizedString(sourceLayer, 'customPrompt'),
},
specReference:
resolveNormalizedReferences(sourceLayer, 'reference', availableLayers)[0] ??
null,
resolveNormalizedReferences(
sourceLayer,
'reference',
availableLayers,
)[0] ?? null,
},
sourceLayer,
);
@@ -966,7 +998,11 @@ function createNormalizedGenerationDialogDraft({
...createCharacterGenerationDialogDraft({
canvasSize,
viewport,
imageModel: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL),
imageModel: getNormalizedString(
sourceLayer,
'model',
DEFAULT_IMAGE_MODEL,
),
}),
prompt,
sourceLayerId: sourceLayer.id,
@@ -977,8 +1013,11 @@ function createNormalizedGenerationDialogDraft({
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'),
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
characterSpecReference:
resolveNormalizedReferences(sourceLayer, 'specReference', availableLayers)[0] ??
null,
resolveNormalizedReferences(
sourceLayer,
'specReference',
availableLayers,
)[0] ?? null,
characterReferences: references,
}),
sourceLayer,
@@ -991,7 +1030,11 @@ function createNormalizedGenerationDialogDraft({
...createIconGenerationDialogDraft({
canvasSize,
viewport,
imageModel: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL),
imageModel: getNormalizedString(
sourceLayer,
'model',
DEFAULT_IMAGE_MODEL,
),
}),
prompt,
sourceLayerId: sourceLayer.id,
@@ -1003,8 +1046,11 @@ function createNormalizedGenerationDialogDraft({
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
iconDescriptions: prompt ? [prompt] : [],
iconSpecReference:
resolveNormalizedReferences(sourceLayer, 'specReference', availableLayers)[0] ??
null,
resolveNormalizedReferences(
sourceLayer,
'specReference',
availableLayers,
)[0] ?? null,
generationReferences: references,
}),
sourceLayer,
@@ -1028,8 +1074,11 @@ function createNormalizedGenerationDialogDraft({
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '16:9'),
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
uiDesignSpecReference:
resolveNormalizedReferences(sourceLayer, 'specReference', availableLayers)[0] ??
null,
resolveNormalizedReferences(
sourceLayer,
'specReference',
availableLayers,
)[0] ?? null,
generationReferences: references,
}),
sourceLayer,
@@ -1119,10 +1168,7 @@ function createNormalizedGenerationDialogDraft({
),
],
}) as Omit<CanvasGenerationDialogState, 'id'>;
return placeDraftBesideSourceLayer(
videoDraft,
sourceLayer,
);
return placeDraftBesideSourceLayer(videoDraft, sourceLayer);
}
if (
@@ -1133,12 +1179,19 @@ function createNormalizedGenerationDialogDraft({
{
...(inputs.action === 'audio.sound-effect.generate'
? createSoundEffectGenerationDialogDraft({ canvasSize, viewport })
: createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport })),
: createBackgroundMusicGenerationDialogDraft({
canvasSize,
viewport,
})),
prompt,
sourceLayerId: sourceLayer.id,
soundModel:
inputs.action === 'audio.sound-effect.generate'
? (getNormalizedString(sourceLayer, 'model', DEFAULT_SOUND_EFFECT_MODEL) as GenerateDialogState['soundModel'])
? (getNormalizedString(
sourceLayer,
'model',
DEFAULT_SOUND_EFFECT_MODEL,
) as GenerateDialogState['soundModel'])
: undefined,
soundDurationSeconds:
inputs.action === 'audio.sound-effect.generate'
@@ -1206,8 +1259,11 @@ function createNormalizedGenerationDialogDraft({
}
if (inputs.action === 'image.edit') {
const source =
resolveNormalizedReferences(sourceLayer, 'source', availableLayers)[0];
const source = resolveNormalizedReferences(
sourceLayer,
'source',
availableLayers,
)[0];
const sourceLayerForEdit = source
? (availableLayers ?? []).find(
(layer) => createCanvasLayerReference(layer).id === source.id,
@@ -8,6 +8,7 @@ import type {
export const CANVAS_GENERATION_ACTIONS = [
'image.generate',
'scene.generate',
'spec.generate',
'character.generate',
'icon.generate',
@@ -27,6 +28,7 @@ export const CANVAS_GENERATION_ACTIONS = [
const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set<CanvasGenerationAction>([
'image.generate',
'scene.generate',
'spec.generate',
'character.generate',
'icon.generate',
@@ -14,6 +14,7 @@ import {
buildImageGenerationInputs,
buildQuickEditGenerationInputs,
buildQuickEditModelOptions,
buildSceneGenerationInputs,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
@@ -265,18 +266,20 @@ describe('ImageCanvasGenerationModel', () => {
characterView: '右向三分之二侧身',
customPrompt: '',
}),
).toEqual(expect.objectContaining({
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'character' },
{ id: 'playSetting', title: '玩法设定', value: '平台跳跃' },
{ id: 'artStyle', title: '美术风格', value: '像素风' },
{ id: 'bodyRatio', title: '头身比', value: '3' },
{ id: 'characterView', title: '角色视角', value: '右向三分之二侧身' },
],
references: [],
}));
).toEqual(
expect.objectContaining({
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'character' },
{ id: 'playSetting', title: '玩法设定', value: '平台跳跃' },
{ id: 'artStyle', title: '美术风格', value: '像素风' },
{ id: 'bodyRatio', title: '头身比', value: '3' },
{ id: 'characterView', title: '角色视角', value: '右向三分之二侧身' },
],
references: [],
}),
);
});
it('builds character, icon and edit reference snapshots', () => {
@@ -300,29 +303,31 @@ describe('ImageCanvasGenerationModel', () => {
},
],
),
).toEqual(expect.objectContaining({
version: 2,
action: 'character.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '角色设定', value: '主角骑士' },
]),
references: [
{
id: 'specReference',
title: '角色规范',
label: '角色规范',
refType: 'project-resource',
refId: 'resource-spec',
},
{
id: 'reference',
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
}));
).toEqual(
expect.objectContaining({
version: 2,
action: 'character.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '角色设定', value: '主角骑士' },
]),
references: [
{
id: 'specReference',
title: '角色规范',
label: '角色规范',
refType: 'project-resource',
refId: 'resource-spec',
},
{
id: 'reference',
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
}),
);
expect(
buildIconGenerationInputs(['返回按钮', '设置按钮'], {
@@ -331,41 +336,45 @@ describe('ImageCanvasGenerationModel', () => {
src: '/icon-spec.png',
resourceId: 'resource-icon-spec',
}),
).toEqual(expect.objectContaining({
version: 2,
action: 'icon.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮' },
]),
references: [
{
id: 'specReference',
title: '图标规范',
label: '图标规范',
refType: 'project-resource',
refId: 'resource-icon-spec',
},
],
}));
).toEqual(
expect.objectContaining({
version: 2,
action: 'icon.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮' },
]),
references: [
{
id: 'specReference',
title: '图标规范',
label: '图标规范',
refType: 'project-resource',
refId: 'resource-icon-spec',
},
],
}),
);
expect(
buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer),
).toEqual(expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '修改要求', value: '换成夜晚' },
]),
references: [
{
id: 'source',
title: '参考图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
}));
).toEqual(
expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '修改要求', value: '换成夜晚' },
]),
references: [
{
id: 'source',
title: '参考图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
}),
);
expect(
buildUiDesignAssetExtractionGenerationInputs(sourceLayer, [
{
@@ -375,32 +384,34 @@ describe('ImageCanvasGenerationModel', () => {
resourceId: 'resource-ui-style',
},
]),
).toEqual(expect.objectContaining({
version: 2,
action: 'ui-design.extract-assets',
fields: expect.arrayContaining([
expect.objectContaining({
id: 'prompt',
title: '提取提示词',
}),
]),
references: [
{
id: 'source',
title: 'UI设计图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
id: 'reference',
title: '参考图 1',
label: 'UI风格参考',
refType: 'project-resource',
refId: 'resource-ui-style',
},
],
}));
).toEqual(
expect.objectContaining({
version: 2,
action: 'ui-design.extract-assets',
fields: expect.arrayContaining([
expect.objectContaining({
id: 'prompt',
title: '提取提示词',
}),
]),
references: [
{
id: 'source',
title: 'UI设计图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
id: 'reference',
title: '参考图 1',
label: 'UI风格参考',
refType: 'project-resource',
refId: 'resource-ui-style',
},
],
}),
);
expect(
buildQuickEditGenerationInputs(
'快速编辑提示词',
@@ -415,29 +426,31 @@ describe('ImageCanvasGenerationModel', () => {
},
],
),
).toEqual(expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '快速编辑提示词', value: '让图2下雨' },
]),
references: [
{
id: 'source',
title: '原图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
id: 'reference',
title: '参考图 1',
label: '天空',
refType: 'project-resource',
refId: 'resource-sky',
},
],
}));
).toEqual(
expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '快速编辑提示词', value: '让图2下雨' },
]),
references: [
{
id: 'source',
title: '原图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
id: 'reference',
title: '参考图 1',
label: '天空',
refType: 'project-resource',
refId: 'resource-sky',
},
],
}),
);
});
it('only exposes redraw for executable generation metadata', () => {
@@ -452,6 +465,43 @@ describe('ImageCanvasGenerationModel', () => {
generationInputs: buildImageGenerationInputs('生成城堡'),
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
assetKind: 'scene',
generationInputs: buildSceneGenerationInputs(
'雨夜车站',
'watercolor',
'',
[],
{
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
},
),
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
assetKind: 'scene',
generationInputs: {
fields: [{ title: '画面内容', value: '历史场景' }],
references: [],
},
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
assetKind: 'image',
generationInputs: {
fields: [{ title: '画面内容', value: '非场景展示字段' }],
references: [],
},
}),
).toBe(false);
expect(
canOpenRedrawPanel({
...generatedLayer,
@@ -564,9 +614,7 @@ describe('ImageCanvasGenerationModel', () => {
generationInputs: {
version: 2,
action: 'image.generate',
fields: [
{ id: 42, title: '生成提示词', value: '损坏数据' } as never,
],
fields: [{ id: 42, title: '生成提示词', value: '损坏数据' } as never],
references: [],
},
}),
@@ -1,6 +1,7 @@
import {
CUSTOM_EDITOR_SCENE_STYLE_PRESET,
getEditorSceneStylePresetLabel,
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
EDITOR_SCENE_STYLE_PRESET_OPTIONS,
} from '../../../packages/shared/src/contracts/editorScene';
import { ApiClientError } from '../../services/apiClient';
import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient';
@@ -1385,6 +1386,21 @@ export function decodeCanvasGenerationInputs(
defaultModel: DEFAULT_IMAGE_MODEL,
defaultAspectRatio: '1:1',
});
} else if (action === 'scene.generate') {
normalizeStringField('prompt', '画面内容');
const stylePreset = normalizeStringOption(
'stylePreset',
'视觉风格',
EDITOR_SCENE_STYLE_PRESET_OPTIONS.map((option) => option.value),
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
);
if (stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET) {
normalizeStringField('customStyle', '自定义画风');
}
normalizeImageParameters({
defaultModel: DEFAULT_IMAGE_MODEL,
defaultAspectRatio: '16:9',
});
} else if (action === 'ui-design.generate') {
normalizeStringField('prompt', '用户输入');
normalizeImageParameters({
@@ -1564,9 +1580,23 @@ export function canOpenRedrawPanel(layer: CanvasLayer) {
if (action !== undefined) {
return isRemixableCanvasGenerationAction(action);
}
if (layer.sourceType === 'uploaded' || layer.generationInputs?.version === 2) {
if (
layer.sourceType === 'uploaded' ||
layer.generationInputs?.version === 2
) {
return false;
}
// 72f268e0 至 scene.generate V2 落地前的场景产物只保存 legacy 中文字段。
// 兼容范围限定为 scene assetKind,避免把同名展示字段重新提升为通用执行能力。
if (
layer.assetKind === 'scene' &&
layer.generationInputs?.fields.some(
(field) =>
typeof field?.title === 'string' && field.title.trim() === '画面内容',
)
) {
return true;
}
return Boolean(
layer.generationInputs?.fields.some(
(field) =>
@@ -1630,20 +1660,38 @@ export function buildSceneGenerationInputs(
stylePreset: NonNullable<GenerateDialogState['sceneStylePreset']>,
customStyle: string | null | undefined,
references?: CharacterReferenceImage[],
options: { model?: string; aspectRatio?: string; imageSize?: string } = {},
): CanvasGenerationInputs {
const styleLabel = getEditorSceneStylePresetLabel(stylePreset);
return {
fields: [
...createGenerationInputField('画面内容', sceneContent),
...createGenerationInputField('视觉风格', styleLabel),
return buildNormalizedGenerationInputs(
'scene.generate',
[
...createGenerationInputField('画面内容', sceneContent, 'prompt'),
...createGenerationInputField('视觉风格', stylePreset, 'stylePreset'),
...(stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET
? createGenerationInputField('自定义画风', customStyle)
? createGenerationInputField('自定义画风', customStyle, 'customStyle')
: []),
...createGenerationInputField(
'模型',
options.model ?? DEFAULT_IMAGE_MODEL,
'model',
),
...createGenerationInputField(
'比例',
options.aspectRatio ?? '16:9',
'aspectRatio',
),
...createGenerationInputField(
'尺寸',
options.imageSize ?? '1K',
'imageSize',
),
],
references: (references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`场景参考图 ${index + 1}`, reference),
(references ?? []).flatMap((reference, index) =>
createGenerationInputReference(`场景参考图 ${index + 1}`, reference, {
id: 'reference',
}),
),
};
);
}
export function buildVideoGenerationInputs(
@@ -2154,7 +2202,7 @@ export function isCanvasGenerationDialog(
dialog?.id &&
(dialog.mode === 'generate' ||
dialog.mode === 'scene' ||
dialog.mode === 'spec' ||
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon' ||
dialog.mode === 'publication' ||
@@ -116,18 +116,33 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
assetKind: 'scene',
title: '游戏场景 2',
generationInputs: {
version: 2,
action: 'scene.generate',
fields: [
{ title: '画面内容', value: '雨夜中的欧洲小镇街道' },
{ title: '视觉风格', value: '清透水彩' },
{
id: 'prompt',
title: '画面内容',
value: '雨夜中的欧洲小镇街道',
},
{ id: 'stylePreset', title: '视觉风格', value: 'watercolor' },
{
id: 'model',
title: '模型',
value: IMAGE_MODEL_NANOBANANA2,
},
{ id: 'aspectRatio', title: '比例', value: '16:9' },
{ id: 'imageSize', title: '尺寸', value: '1K' },
],
references: [
{
id: 'reference',
title: '场景参考图 1',
label: '场景参考一',
refType: 'project-resource',
refId: 'scene-resource-1',
},
{
id: 'reference',
title: '场景参考图 2',
label: '场景参考二',
refType: 'project-resource',
@@ -632,6 +632,11 @@ export function buildImageGenerationSubmissionPlan({
stylePreset,
customStyle,
references,
{
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '16:9',
imageSize: dialog.imageSize ?? '1K',
},
);
return {
kind: 'scene',
@@ -130,6 +130,7 @@ export type EditorAssetGenerationInputs = {
version?: 2;
action?:
| 'image.generate'
| 'scene.generate'
| 'spec.generate'
| 'character.generate'
| 'icon.generate'