Feat/游戏场景需求v1.0 (#139)
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

实现[游戏场景需求v1.0](https://kcnz41bksl1c.feishu.cn/wiki/JSF3wdhduinpqhkrVGKcZFp4nng?psg_id=8477599259997387860&refer_index=1&refer_type=citation)。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: suzmii <suzmii@foxmail.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/139
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
This commit was merged in pull request #139.
This commit is contained in:
2026-08-08 10:25:42 +08:00
committed by 段舒康
parent fc0dcb0782
commit 72f268e088
64 changed files with 3441 additions and 120 deletions
@@ -95,6 +95,7 @@ Use video/audio reference arrays only with models that support them. Do not pass
Use OpenAPI as the final authority; these common values are a routing aid:
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
@@ -104,6 +104,8 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
## Art Spec and Image Request
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt:
```json
+1
View File
@@ -19,6 +19,7 @@
- [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md)
- [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md)
- [图片画布游戏场景生成链路](./technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md)
- [画板音乐生成入口设计](./【编辑器】画板音乐生成入口设计-2026-06-18.md)
- [音频生成 Composer 恢复共享分流方案](./project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md)
- [BGM 提示词优化 T6 测试与发布门禁](./【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md)
@@ -3299,7 +3299,7 @@
"ui-design",
"publication-material"
],
"description": "省略时生成普通图片;其它值选择对应的专用生成流程。"
"description": "省略时生成普通图片;其它值选择对应的专用生成流程。External v1 当前不开放结构化游戏场景生成,scene 不能通过该通用图片接口提交。"
},
"model": {
"type": "string",
@@ -3340,10 +3340,18 @@
]
},
"assetKind": {
"type": [
"string",
"null"
]
"anyOf": [
{
"type": "string",
"not": {
"pattern": "^\\s*scene\\s*$"
}
},
{
"type": "null"
}
],
"description": "生成产物分类。External v1 通用图片接口禁止使用 scene;结构化游戏场景必须使用主站场景专用契约。"
},
"generationInputs": {
"$ref": "#/components/schemas/JsonValue"
+16 -1
View File
@@ -369,6 +369,14 @@
- 验证:画板生成 workflow 测试覆盖 queueState 持续 `running` 到前端等待窗口结束时,不进入 failed、不显示该排队文案、不添加本地临时结果层。
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx`
## 场景队列终态不保证首次项目快照已经收口生成占位
- 现象:游戏场景任务已经显示完成,但画布仍保留 `generating` 占位;场景链路又禁止用本地结果补层,因此当前会话可能一直停在生成中。
- 原因:外部生成任务终态与项目画布投影不是同一个原子观测点。队列轮询先看到 `completed` 后,紧接着的首次项目 GET 仍可能读到同一 `dialogId` 的未收口占位;若调用统一回读函数时没有传 completion dialog ID,函数无法识别该快照仍未完成,也不会执行已有的有界延迟重读。
- 处理:游戏场景队列调用要把本次占位 `dialogId` 传给 `applyQueuedEditorGenerationProject`。首个快照中该 ID 仍为 unresolved 时,只按既有间隔补读一次项目;不追加本地图层,也不把任务终态直接等同于画布投影终态。其他生成类型若要补同类保护,必须分别复现其权威回填时序后再改,不能用本条场景结论替代验证。
- 验证:场景 workflow 用两个连续快照复现时序:第一个保留 `scene / generating`,第二个包含场景结果并把同一占位置为 `idle`。修复前只读一次并超时,修复后依次应用两个权威快照。
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx``docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md`
## 图片画布历史不能回退当前权威状态或复活后端已删素材
- 现象:生成占位框移动后开始生成,撤销移动会把仍在运行的生成对象恢复成待生成状态;切换到 2K 或改变比例后撤销位置,旧占位框还可能把当前尺寸回退。上传图层落库后,普通移动撤销可能被提示“可能会使图片消失”并永久卡在栈顶;即使安全检查已放行,直接恢复旧图层快照也会丢失刚回填的资源关联。素材库后端删除关联素材后,更早的移动快照还可能把已删图层重新加入并自动保存;修改素材类型虽然界面提示撤销成功,刷新后却可能从仍指向新类型的 resource 回弹。无稳定 ID 的“修改图片”草稿也可能被 target-null 快照直接关闭。
@@ -538,6 +546,14 @@
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx``cargo test -p api-server inline_data_url --manifest-path server-rs/Cargo.toml`
- 关联:`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/services/image-editor/editorProjectClient.ts``server-rs/crates/api-server/src/editor_generation_queue.rs``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 专用生成契约不能被通用生成接口和任务摘要绕过
- 现象:专用场景接口要求结构化 `sceneContent + stylePreset`,但调用方仍可向通用图片接口传 `kind = scene``assetKind = scene`,用任意完整 Prompt 生成并持久化正式场景;合法场景入队后,任务侧栏还可能显示后端完整规则文本和通用“生成图片”标题,空白素材名则可能回退成完整 Prompt。
- 原因:专用 handler 内部复用了通用图片 payload、队列和 Worker,但公开通用 HTTP handler 没有限制专用身份;任务摘要又无条件优先提取 payload 顶层 `prompt`,素材名默认值只处理了字段省略,没有处理空白字符串。
- 处理:公开通用 handler 拒绝专用 `kind / assetKind`,专用 handler 仍可直接调用内部共享执行函数;队列投影按 `kind = scene` 从权威 `generationInputs.fields[画面内容]` 派生标题和摘要,缺字段时失败关闭而不是回退内部 Prompt,并重新计算历史缓存;专用素材名统一把省略和空白收口为产品默认值。
- 验证:路由测试先证明旁路会越过 HTTP 边界,再断言两种旁路均返回 `400` 且指向专用端点;摘要测试覆盖新任务、历史错误缓存和缺少画面内容三种情况;标签测试覆盖省略、空白、自定义和 80 字上限。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/spacetime-module/src/external_generation.rs``docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md`
## 图片编辑器角色动画必须提交稳定图片引用
- 现象:图片编辑器里对尚未上传的角色图点击 `生成动画` 后,前端或后端返回 `sourceImageSrc 必须先上传 OSS`
@@ -4091,7 +4107,6 @@
- 现象:延迟重试携带比既有行更旧的调用方时间,回填图片序列字段时若无条件写入,会使 `updated_at` 倒退,导致基于时间戳的同步看不到更新或排序错误。
- 处理:同源图片序列字段只允许 `None → Some`,非空冲突失败关闭;发生回填时 `updated_at = max(existing.updated_at, request_timestamp)`。legacy 音频 repair 不派生资源级图片序列或通用时长,重放继续精确匹配。
## 历史钱包消费不能从最近流水或通用订单快照推算
- 现象:后台用户详情要展示累计花费时,直接复用只返回最近 50 条的 `list_profile_wallet_ledger`,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。
@@ -0,0 +1,166 @@
# 图片画布游戏场景生成链路
更新时间:`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`,不随生成请求提交。保留原分辨率,只做轻量无损压缩;画风菜单打开时不同时挂载全部预览图,只在用户悬停、键盘聚焦或点击对应预览入口时按需挂载当前图片。点击同一入口可关闭预览,保证无 hover 的触屏设备可用。
## 4. API 契约
主站新增:
```text
POST /api/editor/scenes/generations
```
请求字段:
```text
sceneContent
stylePreset
customStyle?
model?
aspectRatio?
imageSize?
referenceImageSrcs?
projectId?
generationInputs?
assetFolderId?
assetLabel?
canvasCompletion?
```
请求不接受前端组装后的完整 `prompt`。通用 `/api/editor/images/generations``/api/external/v1/editor/images/generations` 共用同一边界校验,均拒绝 `kind = scene``assetKind = scene`,防止调用方绕过结构化字段校验和后端 Prompt 组装。External v1 当前没有场景专用路由,因此不能通过通用图片接口提交结构化场景;主站 `/api/editor/scenes/generations` 构造规范请求后直接复用队列,不经过通用入口校验。
完整 Provider Prompt 仍只能由后端生成。
场景参考图沿用普通图片生成的客户端前置门禁,最多 5 张;超限时不得发送 HTTP 请求。场景生成 POST 使用生成专用零重试策略,避免 inline 响应丢失后重复调用 Provider。
后端对模型、比例和清晰度先沿用 `normalize_editor_generation_options` 标准化,再使用标准化比例决定画幅描述和入队价格。
## 5. Prompt
场景 Prompt 在 `server-rs/crates/api-server/src/prompt/editor_scene.rs` 中维护,经 `api-server::prompt` 现役模块出口编译,沿用现有后端 Prompt 常量和 builder 模式,不拆分为运行时文本模板文件,不调用 LLM 润色、扩写或改写。旧 `prompt/scene_background.rs` 属于已退役的自定义世界 / RPG 场景链路,不作为本功能复用入口。
提示词来源为产品提供的 `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`
队列标题固定为“图片画布生成游戏场景”。任务摘要只展示 `generationInputs.fields` 中的“画面内容”,不得把队列 payload 里的后端完整 Prompt 暴露到任务侧栏;历史错误摘要在投影刷新时按同一规则重新派生。`assetLabel` 省略、空字符串或纯空白时统一使用“游戏场景”,不能退回完整 Prompt 作为素材名称。
计费规则:
- 前端展示价继续读取 `/api/editor/generation-pricing`
- 入队时按标准化后的模型和清晰度由后端计算并固化 `external_generation_job.price_mud_points`
- Worker 按入队价格扣费,不能在场景接口增加第二层扣费。
- `scene` 明确按普通图片模型档位计价。
- Provider 失败、执行取消和 lease 耗尽沿用现有扣退费语义。
- Provider 成功后的持久化或画布写回失败语义不在本期调整。
- inline 响应中的通用生成告警必须在队列终态处理前转发;有项目画布占位但响应缺少权威 `project` 快照时,不得追加本地图层或把占位标记为已完成。
- 队列任务进入 `completed` 后,首次项目快照仍可能短暂保留同一 `dialogId``generating` 占位。场景提交必须把 completion 的 `dialogId` 传给统一项目回读逻辑;首个权威快照仍未收口时做一次有界延迟重读,不能把该快照当作最终状态永久套用。
## 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。
- 后端正确组装四种预设、一个自定义风格和三类画幅。
- 展示价、入队价、实际扣费和资源成本一致。
- 任务复用现有等待、失败、轮询、资源入库和画布添加逻辑。
- 场景产物以 `assetKind = scene` 持久化,画布图层右上角显示“场景”标签,不降级为“未知”。
@@ -32,7 +32,7 @@ v1 只开放以下能力:
- `POST /api/external/v1/editor/assets`:创建素材记录。
- `PATCH /api/external/v1/editor/assets/{assetId}`:更新素材名称或所在文件夹。
- `DELETE /api/external/v1/editor/assets/{assetId}`:删除素材记录。
- `POST /api/external/v1/editor/images/generations`:异步提交编辑器图片素材生成;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`
- `POST /api/external/v1/editor/images/generations`:异步提交编辑器图片素材生成;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`External v1 当前不开放结构化游戏场景生成,`kind = scene``assetKind = scene` 均在入队前返回 `400`
- `POST /api/external/v1/editor/images/edits`:异步提交已有图片重绘 / 调整。
- `POST /api/external/v1/editor/icon-spritesheets/generations`:异步提交规范图驱动的图标 spritesheet 生成和拆分。
- `POST /api/external/v1/editor/ui-designs/assets/extractions`:异步提交 UI 设计图素材提取 / 拆分。
@@ -214,7 +214,7 @@ SpacetimeDB procedure
外部生成接口复用站内编辑器已有 DTO、入队器和 worker executor,不维护第二套生成语义:
- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations``/api/editor/images/edits` 的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。
- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations``/api/editor/images/edits` 的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。主站和 External 的通用图片入口共用场景专用合同边界校验,禁止用 `kind = scene``assetKind = scene` 绕过后端场景 Prompt 组装;场景专用 handler 自己构造规范请求,不受该通用入口校验影响。
- 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。
- 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;请求携带 `assetFolderId` 时按站内规则写入素材库,音频类外部调用使用 API Key 所属账号作为 asset owner。
- API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。
@@ -254,6 +254,7 @@ docs/openapi/genarrative-external-v1.openapi.json
- API Key 创建只返回一次明文,列表不返回明文。
- 撤销后的 API Key 调用外部接口返回 `401`
- 八类外部生成 POST 缺少或携带非法 `Idempotency-Key` 时返回 `400`;同一 owner、请求和 key 重试只得到同一 operation。
- External 通用图片生成携带 `kind = scene``assetKind = scene` 时均返回 `400`,且不得产生入队尝试。
- 八类外部生成 POST 固定返回 `202`,查询能从 `queued/running` 收敛到 `completed/failed`;调用方超时后使用原 operationId 继续查询。
- 外部图片生成、重绘、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐 completed 后,生成结果按请求同时出现在画布资源和账号级素材库。
- 角色图、图标 spritesheet 和 UI 素材提取的 completed result 允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。
@@ -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
@@ -6,6 +6,7 @@ export type * from './contracts/creationAudio';
export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent';
export type * from './contracts/editorAudio';
export * from './contracts/editorScene';
export * from './contracts/edutainmentBabyDrawing';
export * from './contracts/edutainmentBabyObject';
export * from './contracts/externalGeneration';
+200
View File
@@ -1912,6 +1912,206 @@ mod tests {
);
}
#[tokio::test]
async fn editor_image_generation_rejects_scene_contract_bypasses() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138232", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_bypass");
let app = build_router(state);
let requests = [
(
"scene kind",
serde_json::json!({
"prompt": "绕过后端场景 Prompt 组装",
"kind": "scene",
}),
),
(
"scene asset kind",
serde_json::json!({
"prompt": "把普通图片伪装成正式场景产物",
"assetKind": "scene",
}),
),
];
for (case_name, request_body) in requests {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/images/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{case_name} must not bypass the dedicated scene contract"
);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("/api/editor/scenes/generations"),
"{case_name} should point callers to the scene endpoint: {body_text}"
);
}
}
#[tokio::test]
async fn editor_scene_generation_requires_bearer_auth() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn editor_scene_generation_rejects_invalid_required_inputs() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_validation");
let app = build_router(state);
let requests = [
(
"empty sceneContent",
serde_json::json!({
"sceneContent": " ",
"stylePreset": "anime",
}),
"sceneContent",
),
(
"unknown stylePreset",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "unknown",
}),
"stylePreset",
),
(
"missing customStyle",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "custom",
}),
"customStyle",
),
];
for (case_name, request_body, expected_message) in requests {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{case_name} should return 400"
);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains(expected_message),
"{case_name} should report {expected_message}: {body_text}"
);
}
}
#[tokio::test]
async fn editor_scene_generation_rejects_inline_data_url_before_queueing() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_reference");
let app = build_router(state);
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
"referenceImageSrcs": ["data:image/png;base64,AAAA"],
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("先上传 OSS"),
"handler should reject inline editor scene references: {body_text}"
);
}
#[tokio::test]
async fn editor_pixel_art_style_wrong_types_return_bad_request() {
let state = AppState::new(AppConfig {
@@ -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
@@ -321,6 +321,15 @@ async fn enqueue_serialized_editor_generation_job_with_identity(
job_id: String,
dedupe_key: String,
) -> Result<ExternalGenerationJobRecord, AppError> {
#[cfg(test)]
if state.record_test_editor_generation_enqueue_attempt() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
"message": "测试已在 SpacetimeDB 写入前截获编辑器生成入队",
})),
);
}
let now_micros = current_utc_micros();
state
.spacetime_client()
@@ -34,7 +34,7 @@ use sha2::{Digest as _, Sha256};
use shared_contracts::assets::{
EDITOR_ASSET_LABEL_MAX_CHARS,
EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest,
EditorCanvasGenerationPlaceholderPayload,
EditorCanvasGenerationPlaceholderPayload, EditorSceneGenerateRequest,
};
use shared_contracts::editor_generation::editor_generation_request_fingerprint;
use shared_contracts::editor_generation::{
@@ -108,6 +108,7 @@ use crate::{
require_openai_image_settings,
},
platform_errors::map_oss_error,
prompt::editor_scene::build_editor_scene_prompt,
request_context::RequestContext,
state::AppState,
work_author::{ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID},
@@ -256,6 +257,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)]
@@ -2466,6 +2468,169 @@ 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 })
}
fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default)
}
fn normalize_editor_scene_generation_options(
model: Option<&str>,
aspect_ratio: Option<&str>,
image_size: Option<&str>,
) -> EditorGenerationOptions {
normalize_editor_generation_options(
Some(normalize_editor_scene_optional_text(
model,
EDITOR_IMAGE_MODEL_NANOBANANA2,
)),
Some(normalize_editor_scene_optional_text(aspect_ratio, "16:9")),
Some(normalize_editor_scene_optional_text(image_size, "1K")),
)
}
fn editor_image_generation_request_label(kind: Option<&str>) -> &'static str {
if kind.is_some_and(|kind| kind.trim() == "scene") {
"图片画布生成游戏场景"
} else {
"图片画布生成图片"
}
}
fn normalize_editor_scene_asset_label(asset_label: Option<String>) -> String {
resolve_editor_generated_asset_label(asset_label, "游戏场景")
}
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_scene_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
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: Some(normalize_editor_scene_asset_label(payload.asset_label)),
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(crate) fn ensure_generic_editor_image_generation_contract(
payload: &EditorImageGenerationRequest,
) -> Result<(), AppError> {
if payload
.kind
.as_deref()
.is_some_and(|kind| kind.trim() == "scene")
|| payload
.asset_kind
.as_deref()
.is_some_and(|asset_kind| asset_kind.trim() == "scene")
{
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-generation",
"message": "游戏场景必须通过 /api/editor/scenes/generations 提交结构化场景意图",
})),
);
}
Ok(())
}
pub async fn generate_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -2473,6 +2638,7 @@ pub async fn generate_editor_image(
payload: Result<Json<EditorImageGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
ensure_generic_editor_image_generation_contract(&payload)?;
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
let queue_job = enqueue_editor_image_generation_for_owner(
@@ -2583,7 +2749,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
caller.owner_user_id.as_str(),
EDITOR_IMAGE_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成图片",
editor_image_generation_request_label(normalized_kind),
price_mud_points,
&payload,
external_idempotency_key,
@@ -2705,6 +2871,7 @@ pub(crate) async fn generate_editor_image_for_owner(
Some("quick-edit") => "图片画布快速编辑图片",
Some("ui-design") => "图片画布生成UI设计图",
Some("publication-material") => "图片画布生成宣发素材",
Some("scene") => "图片画布生成游戏场景",
_ => "图片画布生成图片",
};
let reference_sources = payload
@@ -3345,6 +3512,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,
}
}
@@ -3450,6 +3618,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",
@@ -21588,4 +21761,114 @@ 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,
);
assert_eq!(
editor_image_generation_request_label(Some(" scene ")),
"图片画布生成游戏场景",
);
assert_eq!(
editor_image_generation_request_label(Some("character")),
"图片画布生成图片",
);
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");
}
#[test]
fn scene_generation_blank_options_match_omitted_defaults() {
let omitted = normalize_editor_scene_generation_options(None, None, None);
let blank =
normalize_editor_scene_generation_options(Some(" "), Some(" \t "), Some("\n "));
let explicit = normalize_editor_scene_generation_options(
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
Some("16:9"),
Some("1K"),
);
assert_eq!(omitted, explicit);
assert_eq!(blank, explicit);
assert_eq!(explicit.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(explicit.aspect_ratio, "16:9");
assert_eq!(explicit.image_size, "1K");
assert_eq!(
crate::editor_generation_config::editor_image_generation_mud_points(
Some("scene"),
Some(explicit.model),
Some(explicit.image_size),
),
12,
);
}
#[test]
fn scene_generation_blank_asset_label_uses_product_default() {
assert_eq!(normalize_editor_scene_asset_label(None), "游戏场景");
assert_eq!(
normalize_editor_scene_asset_label(Some(" \t ".to_string())),
"游戏场景"
);
assert_eq!(
normalize_editor_scene_asset_label(Some(" 雨夜车站 ".to_string())),
"雨夜车站"
);
assert_eq!(
normalize_editor_scene_asset_label(Some("".repeat(81)))
.chars()
.count(),
EDITOR_ASSET_LABEL_MAX_CHARS
);
}
#[test]
fn scene_generation_rebuilds_fields_and_only_keeps_reference_rows() {
let payload: EditorSceneGenerateRequest = serde_json::from_value(json!({
"sceneContent": " 雨夜中的海边车站 ",
"stylePreset": "custom",
"customStyle": "复古像素风",
"generationInputs": {
"fields": [
{ "title": "伪造字段", "value": "不得透传" }
],
"references": [
{
"title": "参考图",
"label": "车站参考",
"refType": "project-resource",
"refId": "editor-resource-scene-reference"
}
]
}
}))
.expect("scene request should deserialize");
let options = normalize_editor_generation_options(
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
Some("16:9"),
Some("1K"),
);
let generation_inputs = build_editor_scene_generation_inputs(&payload, &options);
let serialized = generation_inputs.to_string();
assert!(!serialized.contains("伪造字段"));
assert!(!serialized.contains("不得透传"));
assert!(serialized.contains("雨夜中的海边车站"));
assert!(serialized.contains("复古像素风"));
assert_eq!(
generation_inputs["references"],
json!([{
"title": "参考图",
"label": "车站参考",
"refType": "project-resource",
"refId": "editor-resource-scene-reference"
}])
);
}
}
@@ -27,6 +27,15 @@ pub struct ExternalApiPrincipal {
}
impl ExternalApiPrincipal {
#[cfg(test)]
pub(crate) fn for_test(owner_user_id: &str, scopes: &[&str]) -> Self {
Self {
owner_user_id: owner_user_id.to_string(),
key_id: "external-api-key-test".to_string(),
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
}
}
pub fn owner_user_id(&self) -> &str {
self.owner_user_id.as_str()
}
@@ -39,7 +39,8 @@ use crate::{
editor_project_resource_payload_from_record,
enqueue_editor_icon_spritesheet_generation_for_owner, enqueue_editor_image_edit_for_owner,
enqueue_editor_image_generation_for_owner,
enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error,
enqueue_editor_ui_design_asset_extraction_for_owner,
ensure_generic_editor_image_generation_contract, map_editor_project_error,
normalize_editor_persisted_media_src, normalize_optional_string,
parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs,
save_editor_project_layout_with_revision_and_get, serialize_editor_generation_inputs,
@@ -737,6 +738,7 @@ pub async fn generate_external_editor_image(
let Json(payload) = parse_editor_generation_json_payload(payload)?;
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let idempotency_key = require_idempotency_key(&headers)?;
ensure_generic_editor_image_generation_contract(&payload)?;
let project_id = payload.project_id.clone();
let job = enqueue_editor_image_generation_for_owner(
&state,
@@ -1117,9 +1119,11 @@ fn serialize_external_editor_image_sequence_frames(
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, body::Body, routing::post};
use spacetime_client::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
};
use tower::ServiceExt;
fn external_editor_project_resource_fixture(
resource_id: &str,
@@ -1622,6 +1626,80 @@ mod tests {
);
}
async fn assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
case_name: &str,
idempotency_key: &str,
request_body: Value,
) {
let state = AppState::new(crate::config::AppConfig::default())
.expect("external image test state should build");
state.fail_test_editor_generation_enqueue();
let app = Router::new()
.route(
"/api/external/v1/editor/images/generations",
post(generate_external_editor_image),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
"user-external-scene-bypass",
&[SCOPE_EDITOR_IMAGE_GENERATE],
)))
.with_state(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/generations")
.header("content-type", "application/json")
.header(IDEMPOTENCY_KEY_HEADER, idempotency_key)
.body(Body::from(request_body.to_string()))
.expect("external scene bypass request should build"),
)
.await
.expect("external scene bypass response should return");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("external scene bypass response body should collect");
let body_text = String::from_utf8_lossy(&body);
assert_eq!(
(status, state.test_editor_generation_enqueue_attempts()),
(StatusCode::BAD_REQUEST, 0),
"{case_name} must fail at the generic External boundary before queueing: {body_text}",
);
assert!(
body_text.contains("/api/editor/scenes/generations"),
"{case_name} should direct callers to the dedicated scene contract: {body_text}",
);
}
#[tokio::test]
async fn external_generic_image_generation_rejects_scene_kind_before_queueing() {
assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
"scene kind",
"scene-bypass-kind",
json!({
"prompt": "绕过后端场景 Prompt 组装",
"kind": "scene",
}),
)
.await;
}
#[tokio::test]
async fn external_generic_image_generation_rejects_scene_asset_kind_before_queueing() {
assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
"scene asset kind",
"scene-bypass-asset-kind",
json!({
"prompt": "把普通图片伪装成正式场景产物",
"assetKind": "scene",
}),
)
.await;
}
#[test]
fn generation_lookup_hides_cross_owner_jobs_as_not_found() {
let not_found = map_external_generation_lookup_error(SpacetimeClientError::Procedure(
@@ -1847,6 +1925,29 @@ mod tests {
.get("default")
.is_none()
);
let image_kind_schema =
&parsed["components"]["schemas"]["EditorImageGenerationRequest"]["properties"]["kind"];
assert!(
image_kind_schema["enum"]
.as_array()
.is_some_and(|values| !values.contains(&json!("scene")))
);
assert!(
image_kind_schema["description"]
.as_str()
.is_some_and(|description| description.contains("不开放结构化游戏场景"))
);
let image_asset_kind_schema = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]
["properties"]["assetKind"];
assert_eq!(
image_asset_kind_schema["anyOf"][0]["not"]["pattern"],
json!(r"^\s*scene\s*$")
);
assert!(
image_asset_kind_schema["description"]
.as_str()
.is_some_and(|description| description.contains("禁止使用 scene"))
);
let generation_references = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]
["properties"]["referenceImageSrcs"];
assert_eq!(generation_references["maxItems"], 9);
@@ -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,
snap_editor_image_to_pixel_art, split_editor_icon_spritesheet,
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, snap_editor_image_to_pixel_art, 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(
@@ -1,3 +1,4 @@
pub(crate) mod background_music;
pub(crate) mod character_animation;
pub(crate) mod character_visual;
pub(crate) mod editor_scene;
@@ -0,0 +1,279 @@
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 SCENE_CONTENT_PLACEHOLDER: &str = "{{SCENE_CONTENT}}";
const STYLE_PROMPT_PLACEHOLDER: &str = "{{STYLE_PROMPT}}";
const ASPECT_RATIO_PLACEHOLDER: &str = "{{ASPECT_RATIO}}";
const FRAME_ORIENTATION_PLACEHOLDER: &str = "{{FRAME_ORIENTATION}}";
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)?;
// 比例和画幅方向来自后端白名单标准化结果,可以在插入用户文本前安全替换。
let controlled_structure = EDITOR_SCENE_STRUCTURE_PROMPT
.replace(ASPECT_RATIO_PLACEHOLDER, normalized_aspect_ratio)
.replace(
FRAME_ORIENTATION_PLACEHOLDER,
editor_scene_frame_orientation(normalized_aspect_ratio),
);
let (before_scene_content, after_scene_content) = controlled_structure
.split_once(SCENE_CONTENT_PLACEHOLDER)
.ok_or("场景 Prompt 模板缺少 SCENE_CONTENT 占位符")?;
let (between_scene_and_style, after_style_prompt) = after_scene_content
.split_once(STYLE_PROMPT_PLACEHOLDER)
.ok_or("场景 Prompt 模板缺少 STYLE_PROMPT 占位符")?;
// 用户画面内容和自定义画风必须最后一次性拼入;拼入后禁止再对整段 Prompt
// 执行模板替换,避免用户原文中的 {{...}} token 被当成内部占位符展开。
Ok(format!(
"{before_scene_content}{scene_content}{between_scene_and_style}{style_prompt}{after_style_prompt}"
))
}
#[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}"
);
assert_eq!(prompt.matches("视觉风格:").count(), 1);
}
}
#[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_does_not_expand_placeholder_tokens_from_user_inputs() {
let scene_content = "保留 {{STYLE_PROMPT}}、{{ASPECT_RATIO}} 与 {{FRAME_ORIENTATION}} 原文";
let custom_style = "保留 {{SCENE_CONTENT}}、{{ASPECT_RATIO}} 与 {{FRAME_ORIENTATION}} 原文";
let prompt =
build_editor_scene_prompt(scene_content, STYLE_CUSTOM, Some(custom_style), "16:9")
.expect("custom scene prompt");
assert!(prompt.contains(scene_content));
assert!(prompt.contains(custom_style));
assert!(prompt.contains("图片比例 16:9 一致(横幅)"));
}
#[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"));
}
assert_eq!(
EDITOR_SCENE_STRUCTURE_PROMPT
.matches(SCENE_CONTENT_PLACEHOLDER)
.count(),
1
);
assert_eq!(
EDITOR_SCENE_STRUCTURE_PROMPT
.matches(STYLE_PROMPT_PLACEHOLDER)
.count(),
1
);
}
}
+30
View File
@@ -1,5 +1,7 @@
#[cfg(test)]
use std::sync::Mutex;
#[cfg(test)]
use std::sync::atomic::AtomicUsize;
use std::{
collections::BTreeMap,
error::Error,
@@ -249,6 +251,10 @@ pub struct AppStateInner {
test_feature_gate_config: Arc<Mutex<Option<Vec<module_runtime::FeatureGateConfigSnapshot>>>>,
#[cfg(test)]
test_spacetime_health: Arc<Mutex<Option<SpacetimeClientHealthSnapshot>>>,
#[cfg(test)]
test_editor_generation_enqueue_attempts: AtomicUsize,
#[cfg(test)]
test_fail_editor_generation_enqueue: AtomicBool,
oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore,
@@ -587,6 +593,10 @@ impl AppState {
test_spacetime_health: Arc::new(Mutex::new(Some(
SpacetimeClientHealthSnapshot::healthy_for_test(),
))),
#[cfg(test)]
test_editor_generation_enqueue_attempts: AtomicUsize::new(0),
#[cfg(test)]
test_fail_editor_generation_enqueue: AtomicBool::new(false),
oss_client,
auth_store,
password_entry_service,
@@ -765,6 +775,26 @@ impl AppState {
.expect("test spacetime health should lock") = Some(snapshot);
}
#[cfg(test)]
pub(crate) fn fail_test_editor_generation_enqueue(&self) {
self.test_fail_editor_generation_enqueue
.store(true, Ordering::Release);
}
#[cfg(test)]
pub(crate) fn record_test_editor_generation_enqueue_attempt(&self) -> bool {
self.test_editor_generation_enqueue_attempts
.fetch_add(1, Ordering::AcqRel);
self.test_fail_editor_generation_enqueue
.load(Ordering::Acquire)
}
#[cfg(test)]
pub(crate) fn test_editor_generation_enqueue_attempts(&self) -> usize {
self.test_editor_generation_enqueue_attempts
.load(Ordering::Acquire)
}
#[cfg(any())]
pub async fn upsert_creation_entry_type_config(
&self,
@@ -375,6 +375,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 {
@@ -50,8 +50,9 @@ const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [
"assetKind",
"generationInputs",
];
const EDITOR_CANVAS_ASSET_KINDS: [&str; 11] = [
const EDITOR_CANVAS_ASSET_KINDS: [&str; 12] = [
"spec",
"scene",
"character",
"character-animation",
"icon",
@@ -10603,14 +10604,13 @@ fn validate_editor_pixel_art_planned_canvas_layout(
.filter(|migration| migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE)
.ok_or_else(|| "结构化图片画布缺少 active 迁移记录,拒绝保存".to_string())?;
let current_structured_json = build_structured_canvas_layout_json(ctx, canvas)?;
let current_structured_hash = canonical_layout_sha256(current_structured_json.as_str())?;
let current_structured_integrity =
canvas_layout_integrity(current_structured_json.as_str())?;
verify_migration_layout(
let current_resource_asset_kinds =
project_resource_asset_kinds(ctx, project_id, owner_user_id);
verify_active_structured_canvas_migration(
&active_migration,
canvas.revision,
current_structured_hash.as_str(),
&current_structured_integrity,
current_structured_json.as_str(),
&current_resource_asset_kinds,
)?;
}
normalize_layout_json(layers_json).and_then(|layers_json| {
@@ -10625,6 +10625,18 @@ fn validate_editor_pixel_art_planned_canvas_layout(
})
}
fn verify_active_structured_canvas_migration(
migration: &EditorCanvasLayoutMigration,
revision: u64,
structured_json: &str,
resource_asset_kinds: &BTreeMap<String, Option<String>>,
) -> Result<(), String> {
let structured_hash =
canonical_layout_sha256_with_resource_asset_kinds(structured_json, resource_asset_kinds)?;
let integrity = canvas_layout_integrity(structured_json)?;
verify_migration_layout(migration, revision, structured_hash.as_str(), &integrity)
}
fn backfill_editor_canvas_layout(
ctx: &ReducerContext,
input: EditorCanvasLayoutMigrationInput,
@@ -15272,6 +15284,48 @@ mod tests {
));
}
#[test]
fn pixel_art_canvas_completion_accepts_scene_asset_kind() {
let layout = json!([{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-1",
"resourceId": "generation-dialog:dialog-1",
"dialog": {
"id": "dialog-1",
"mode": "quick-edit",
"status": "generating",
"placeholder": {
"x": 100,
"y": 50,
"width": 200,
"height": 100,
"originalWidth": 200,
"originalHeight": 100
}
}
}]);
let mut resource = pixel_art_project_resource();
resource.asset_kind = Some("scene".to_string());
let applied = match plan_editor_pixel_art_canvas_layout(
layout.to_string().as_str(),
&pixel_art_canvas_completion(),
&resource,
)
.expect("scene pixel-art completion should apply")
{
EditorPixelArtCanvasLayoutPlan::Apply(layers_json) => layers_json,
_ => panic!("scene completion must apply"),
};
let parsed = parse_structured_canvas_layout(applied.as_str())
.expect("scene result layer should be accepted by structured layout parsing");
let extension: JsonValue = serde_json::from_str(&parsed.layers[0].item_json)
.expect("scene result layer extension");
assert_eq!(parsed.layers.len(), 1);
assert_eq!(extension["assetKind"], json!("scene"));
}
#[test]
fn pixel_art_canvas_completion_keeps_deleted_dialog_absent() {
let layout = json!([{
@@ -17113,6 +17167,77 @@ mod tests {
);
}
#[test]
fn structured_canvas_scene_resource_kind_survives_canonical_normalization() {
let layout = r#"[{"layerId":"layer-1","resourceId":"resource-scene"}]"#;
let resource_asset_kinds =
BTreeMap::from([("resource-scene".to_string(), Some("scene".to_string()))]);
let canonical =
canonical_layout_json_with_resource_asset_kinds(layout, &resource_asset_kinds)
.expect("scene resource kind should canonicalize");
let parsed = parse_structured_canvas_layout(canonical.as_str())
.expect("canonical scene layout should remain structurally valid");
let rebuilt = serialize_structured_canvas_layout(&parsed)
.expect("canonical scene layout should persist structurally");
let rebuilt: JsonValue = serde_json::from_str(&rebuilt).expect("rebuilt scene layout");
assert_eq!(
parsed.layers[0].asset_kind_override.as_deref(),
Some("scene")
);
assert_eq!(rebuilt[0]["assetKindOverride"], json!("scene"));
}
#[test]
fn active_migration_verification_uses_scene_resource_kind() {
let now = Timestamp::from_micros_since_unix_epoch(1_000_000);
let structured_json = r#"[{"layerId":"layer-1","resourceId":"resource-scene"}]"#;
let resource_asset_kinds =
BTreeMap::from([("resource-scene".to_string(), Some("scene".to_string()))]);
let structured_hash = canonical_layout_sha256_with_resource_asset_kinds(
structured_json,
&resource_asset_kinds,
)
.expect("scene-aware migration hash");
let integrity = canvas_layout_integrity(structured_json).expect("scene layout integrity");
let migration = EditorCanvasLayoutMigration {
canvas_id: "canvas-1".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
source_layout_sha256: "source".to_string(),
structured_layout_sha256: structured_hash,
verified_revision: 7,
layer_count: integrity.layer_count,
dialog_count: integrity.dialog_count,
resource_refs_sha256: integrity.resource_refs_sha256,
migration_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED,
status: EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE.to_string(),
migrated_at: now,
activated_at: Some(now),
rolled_back_at: None,
updated_at: now,
};
verify_active_structured_canvas_migration(
&migration,
7,
structured_json,
&resource_asset_kinds,
)
.expect("scene resource kind should match the active migration hash");
assert!(
verify_active_structured_canvas_migration(
&migration,
7,
structured_json,
&BTreeMap::new(),
)
.is_err(),
"dropping the scene resource kind must still be detected as drift"
);
}
#[test]
fn structured_canvas_allows_distinct_layers_to_share_one_resource() {
let shared = r#"[
@@ -2040,18 +2040,30 @@ fn build_external_generation_job_summary_row(
row: &ExternalGenerationJob,
cached_request_prompt: Option<Option<String>>,
) -> ExternalGenerationJobSummary {
let request_payload = serde_json::from_str::<serde_json::Value>(&row.request_payload_json).ok();
let is_scene_generation = row.source_module.trim() == EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE
&& row.job_kind.trim() == "editor_image_generation"
&& request_payload
.as_ref()
.is_some_and(external_generation_payload_is_scene);
ExternalGenerationJobSummary {
job_id: row.job_id.clone(),
job_kind: row.job_kind.clone(),
owner_user_id: row.owner_user_id.clone(),
source_module: row.source_module.clone(),
source_entity_id: row.source_entity_id.clone(),
request_label: row.request_label.clone(),
request_prompt: match cached_request_prompt {
request_label: if is_scene_generation {
"图片画布生成游戏场景".to_string()
} else {
row.request_label.clone()
},
request_prompt: match cached_request_prompt.filter(|_| !is_scene_generation) {
Some(prompt) => prompt
.as_deref()
.and_then(normalize_external_generation_request_prompt_text),
None => extract_external_generation_request_prompt(&row.request_payload_json),
None => request_payload.as_ref().and_then(|payload| {
extract_external_generation_request_prompt(payload, is_scene_generation)
}),
},
status: row.status.clone(),
last_error_message: row
@@ -2072,6 +2084,13 @@ fn build_external_generation_job_summary_row(
}
}
fn external_generation_payload_is_scene(payload: &serde_json::Value) -> bool {
payload
.get("kind")
.and_then(serde_json::Value::as_str)
.is_some_and(|kind| kind.trim() == "scene")
}
fn extract_external_generation_warning_message(
result_payload_json: Option<&str>,
) -> Option<String> {
@@ -2083,8 +2102,32 @@ fn extract_external_generation_warning_message(
normalize_external_generation_warning_message(reason)
}
fn extract_external_generation_request_prompt(request_payload_json: &str) -> Option<String> {
let payload: serde_json::Value = serde_json::from_str(request_payload_json).ok()?;
fn extract_external_generation_request_prompt(
payload: &serde_json::Value,
is_scene_generation: bool,
) -> Option<String> {
if is_scene_generation {
return payload
.get("generationInputs")
.and_then(|value| value.get("fields"))
.and_then(serde_json::Value::as_array)
.and_then(|fields| {
fields.iter().find_map(|field| {
if field
.get("title")
.and_then(serde_json::Value::as_str)?
.trim()
!= "画面内容"
{
return None;
}
field
.get("value")
.and_then(serde_json::Value::as_str)
.and_then(normalize_external_generation_request_prompt_text)
})
});
}
for key in ["prompt", "promptText", "spritesheetLabel"] {
if let Some(prompt) = payload
.get(key)
@@ -2856,6 +2899,7 @@ mod tests {
#[test]
fn pending_job_is_claimable_only_after_available_time() {
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING);
row.request_label = "图片画布生成图片".to_string();
row.available_at = micros(1_000);
assert!(!is_external_generation_job_claimable(&row, micros(999)));
@@ -3185,6 +3229,60 @@ mod tests {
}
}
#[test]
fn scene_job_summary_uses_user_scene_content_instead_of_internal_prompt() {
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING);
row.source_module = EXTERNAL_GENERATION_EDITOR_SOURCE_MODULE.to_string();
row.job_kind = "editor_image_generation".to_string();
row.request_label = "图片画布生成图片".to_string();
row.request_payload_json = serde_json::json!({
"kind": "scene",
"prompt": "【内部场景规则】仅生成环境背景,禁止 UI 和文字",
"generationInputs": {
"fields": [
{ "title": "画面内容", "value": "雨夜中的海边车站" },
{ "title": "视觉风格", "value": "日系动画" }
],
"references": []
}
})
.to_string();
let summary = build_external_generation_job_summary_row(&row, None);
let refreshed = build_external_generation_job_summary_row(
&row,
Some(Some("【内部场景规则】旧摘要缓存".to_string())),
);
assert_eq!(summary.request_prompt.as_deref(), Some("雨夜中的海边车站"));
assert_eq!(summary.request_label, "图片画布生成游戏场景");
assert_eq!(
refreshed.request_prompt.as_deref(),
Some("雨夜中的海边车站")
);
assert_eq!(refreshed.request_label, "图片画布生成游戏场景");
row.request_payload_json = serde_json::json!({
"kind": "scene",
"prompt": "【内部场景规则】缺少用户画面内容时也不得展示",
"generationInputs": { "fields": [], "references": [] }
})
.to_string();
let missing_scene_content = build_external_generation_job_summary_row(&row, None);
assert!(missing_scene_content.request_prompt.is_none());
assert_eq!(missing_scene_content.request_label, "图片画布生成游戏场景");
row.source_module = "puzzle".to_string();
row.job_kind = "puzzle_compile_draft".to_string();
row.request_label = "拼图场景生成".to_string();
let non_editor = build_external_generation_job_summary_row(&row, None);
assert_eq!(non_editor.request_label, "拼图场景生成");
assert_eq!(
non_editor.request_prompt.as_deref(),
Some("【内部场景规则】缺少用户画面内容时也不得展示")
);
}
#[test]
fn job_summary_never_copies_inline_media_as_request_prompt() {
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_COMPLETED);
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

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