diff --git a/docs/README.md b/docs/README.md index b9a0be0c9..98bf94676 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ - [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md) - [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md) +- [图片画布游戏场景生成链路](./technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md) - [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md) - [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md) - [图片画布撤销范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md) diff --git a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md index 880d9fbd4..b289c7084 100644 --- a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md +++ b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md @@ -56,7 +56,7 @@ - FileReader Data URL 正式请求。 - 复制出的整套画布和缩减版类型。 -四张固定图片只用于前端预览,不进入 `generationReferences`,不随生成请求提交。保留原分辨率,只做轻量无损压缩。 +四张固定图片只用于前端预览,不进入 `generationReferences`,不随生成请求提交。保留原分辨率,只做轻量无损压缩;画风菜单打开时不同时挂载全部预览图,只在用户悬停或键盘聚焦对应预览入口时按需挂载当前图片。 ## 4. API 契约 @@ -77,6 +77,7 @@ aspectRatio? imageSize? referenceImageSrcs? projectId? +generationInputs? assetFolderId? assetLabel? canvasCompletion? @@ -84,6 +85,8 @@ canvasCompletion? 请求不接受前端组装后的完整 `prompt`。本期不修改 `/api/external/v1`。 +`generationInputs` 只承载结构化输入快照。后端不信任或透传前端提交的 `fields`,而是根据标准化后的场景请求重新构建画面内容、视觉风格、比例、清晰度和模型字段;前端提交的参考图元数据只使用既有项目资源或账号素材的行引用,不保存媒体 URL。完整 Provider Prompt 仍只能由后端生成。 + 后端对模型、比例和清晰度先沿用 `normalize_editor_generation_options` 标准化,再使用标准化比例决定画幅描述和入队价格。 ## 5. Prompt @@ -152,3 +155,4 @@ prompt = 后端完整 Prompt - 后端正确组装四种预设、一个自定义风格和三类画幅。 - 展示价、入队价、实际扣费和资源成本一致。 - 任务复用现有等待、失败、轮询、资源入库和画布添加逻辑。 +- 场景产物以 `assetKind = scene` 持久化,画布图层右上角显示“场景”标签,不降级为“未知”。 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cac92c64b..13a6f28cd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,7 +5,7 @@ export type * from './contracts/creationAgentDocumentInput'; export type * from './contracts/creationAudio'; export type * from './contracts/creativeAgent'; export type * from './contracts/customWorldAgent'; -export type * from './contracts/editorScene'; +export * from './contracts/editorScene'; export * from './contracts/edutainmentBabyDrawing'; export * from './contracts/edutainmentBabyObject'; export * from './contracts/externalGeneration'; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index bb788cedc..6e56a92f0 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1912,6 +1912,145 @@ mod tests { ); } + #[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 { diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 197ae9145..96fc2cd6c 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -15146,4 +15146,49 @@ mod tests { assert_eq!(profile.path_kind, "scene-images"); assert_eq!(profile.slot, "scene"); } + + #[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" + }]) + ); + } } diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 666a740f4..6b5976345 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -297,6 +297,12 @@ describe('ImageCanvasWorldView', () => { title: '角色标签图层', assetKind: 'character', }), + createLayer({ + id: 'layer-scene', + resourceId: 'resource-scene', + title: '游戏场景', + assetKind: 'scene', + }), createLayer({ id: 'layer-source', resourceId: 'resource-source', @@ -310,10 +316,12 @@ describe('ImageCanvasWorldView', () => { const characterButton = screen.getByRole('button', { name: '选择角色标签图层', }); + const sceneButton = screen.getByRole('button', { name: '选择游戏场景' }); const sourceButton = screen.getByRole('button', { name: '选择角色原图' }); expect(within(layerButton).getByText('未知')).toBeTruthy(); expect(within(characterButton).getByText('角色')).toBeTruthy(); + expect(within(sceneButton).getByText('场景')).toBeTruthy(); expect(within(sourceButton).getByText('角色')).toBeTruthy(); }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 7eb954e19..4e7770f04 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -220,6 +220,7 @@ const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{ className: 'publication-material', }, { value: 'ui-design', label: 'UI设计', className: 'ui-design' }, + { value: 'scene', label: '场景', className: 'scene' }, { value: 'video', label: '视频', className: 'video' }, { value: 'sound-effect', label: '音效', className: 'sound-effect' }, {