From b13870f71bb78b647745855ee393852d31d4c5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Wed, 13 May 2026 03:11:00 +0800 Subject: [PATCH] 1 --- ...FT_ASSET_GENERATION_PIPELINE_2026-05-10.md | 4 +- .../PUZZLE_FORM_CREATION_FLOW_2026-04-29.md | 11 +-- server-rs/crates/api-server/src/match3d.rs | 49 ++++++++++-- .../spacetime-module/src/match3d/mod.rs | 78 +++++++++++++++---- .../match3d-result/Match3DResultView.test.tsx | 72 +++++++++++++++++ .../match3d-result/Match3DResultView.tsx | 75 ++++++++++++------ .../match3dGeneratedModelCache.test.ts | 32 ++++++++ src/services/match3dGeneratedModelCache.ts | 7 +- 8 files changed, 273 insertions(+), 55 deletions(-) diff --git a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md index 6a88fc9c..349b49b7 100644 --- a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md +++ b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md @@ -4,7 +4,7 @@ 本方案用于改造 `生成抓大鹅草稿` 的首版生成链路:点击按钮后先进入独立生成过程页,生成结束后自动进入抓大鹅结果页,并在结果页 `素材配置 > 物品` 预览本次生成的 2D 多视角物品素材。 -草稿生成不再调用 Hyper3D Rodin,也不再生成 GLB 模型。物品素材继续沿用原来的“生成图片 -> 网格拆分 -> 上传 OSS -> 写回草稿”机制,但每个物品必须生成 `5` 个不同视角的 2D 视图。试玩和正式运行态的消除次数、总物品数和物品种类数以结果页 `难度配置` 保存的难度为准。难度对应物品种类固定为:轻松 `3` 种、标准 `9` 种、进阶 `15` 种、硬核 `21` 种。历史硬核草稿若仍保存 `clearCount = 20`,运行态按新硬核升为 `21` 次消除、`63` 件总物品。正式发布前如果已生成 `image_ready` 物品种类不足当前难度要求,必须阻断发布;试玩不阻断,但启动时把物品种类自动降到当前可用 2D 素材数量。 +草稿生成不再调用 Hyper3D Rodin,也不再生成 GLB 模型。物品素材继续沿用原来的“生成图片 -> 网格拆分 -> 上传 OSS -> 写回草稿”机制,但每个物品必须生成 `5` 个不同视角的 2D 视图。试玩和正式运行态的消除次数、总物品数和物品种类数以结果页 `难度配置` 保存的难度为准。难度对应物品种类固定为:轻松 `3` 种、标准 `9` 种、进阶 `15` 种、硬核 `21` 种。历史硬核草稿若仍保存 `clearCount = 20`,运行态按新硬核升为 `21` 次消除、`63` 件总物品。正式发布前如果已生成 `image_ready` 且具备至少 `5` 张有效 `imageViews[]` 的物品种类不足当前难度要求,必须阻断发布;试玩不阻断,但启动时把物品种类自动降到当前可用 2D 素材数量。 ## 2. 前端流程 @@ -173,7 +173,7 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard | 进阶 | 16 | 6 | 48 | 15 | | 硬核 | 21 | 8 | 63 | 21 | -预览区展示 `需要消除`、`总物品数`、`物品种类` 和 `已生成物品种类`。历史草稿如果保存的是旧 `clearCount/difficulty`,前端按 `clearCount` 精确命中优先、否则按 `difficulty` 就近归一到上述选项,并把归一后的数值保存回 profile。发布校验以 `generatedItemAssets[]` 中有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的 `image_ready` 素材数量为准;试玩启动时用同一数量计算 `itemTypeCountOverride`,不足时自动降低,不修改草稿难度配置本身。 +预览区展示 `需要消除`、`总物品数`、`物品种类` 和 `已生成物品种类`。历史草稿如果保存的是旧 `clearCount/difficulty`,前端按 `clearCount` 精确命中优先、否则按 `difficulty` 就近归一到上述选项,并把归一后的数值保存回 profile。发布校验以 `generatedItemAssets[]` 中 `image_ready` 且至少有 `5` 张有效 `imageViews[]` 的素材数量为准;试玩启动时用同一数量计算 `itemTypeCountOverride`,不足时自动降低,不修改草稿难度配置本身。历史单图 `imageSrc/imageObjectKey` 只作为运行态和预览兜底,不计入新发布素材完成数。 结果页 `素材配置` Tab 取代旧一级素材入口,并包含三个子 Tab: diff --git a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md index 5bf65190..9da8ab78 100644 --- a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md +++ b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md @@ -97,7 +97,7 @@ 1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、生成或重新生成画面,并在已有正式图后支持关卡测试。 2. 作品信息:展示并编辑作品名称、作品描述、作品标签。 -3. UI:展示并编辑拼图运行态 UI 背景提示词,支持基于作品名称、作品描述、标签和首关信息重新生成 9:16 UI 背景图。生成 action 为 `generate_puzzle_ui_background`,图片生成在 `api-server` 中读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 背景参考图,并调用 VectorEngine `gpt-image-2-all` 的 `9:16` 图片生成链路。生成结果写入首关 `levels_json` 的 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`,不新增 SpacetimeDB 表字段。 +3. UI:展示并编辑拼图运行态 UI 背景提示词。`compile_puzzle_draft` 草稿编译完成首图和背景音乐后,`api-server` 会基于作品名称、作品描述、标签和首关信息自动生成首关 9:16 UI 背景图;结果页继续支持用户修改提示词并通过 `generate_puzzle_ui_background` 重新生成。图片生成在 `api-server` 中读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 背景参考图,并调用 VectorEngine `gpt-image-2-all` 的 `9:16` 图片生成链路。生成结果写入首关 `levels_json` 的 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`,不新增 SpacetimeDB 表字段。 4. 音乐:编辑并生成背景音乐,音乐资产暂存到首关 `levels_json[0].backgroundMusic`。 ### 2026-05-12 UI 背景生成补充 @@ -105,8 +105,9 @@ 1. UI 背景图只生成拼图棋盘以外的运行态背景与 UI 容器层次,提示词必须要求中央正方形拼图区和外部 UI 背景之间有明确描边、容器或留白边界。 2. UI 背景图不得生成文字、水印、按钮文字、数字、拼图碎片、完整拼图图像或教程浮层,避免与真实拼图图块和运行态 HUD 混淆。 3. 结果页 UI Tab 支持直接修改提示词并重新生成;点击生成前会把本地首关 `uiBackgroundPrompt` 同步进 `levelsJson`,使自动保存尚未完成时后端仍能拿到最新提示词。 -4. `api-server` 负责读取参考图、拼接生成 prompt、调用 VectorEngine、下载并转存 OSS;SpacetimeDB 只通过 `save_puzzle_ui_background` procedure 保存结果,不做外部 I/O。 -5. 拼图运行态读取 `currentLevel.uiBackgroundImageSrc` 渲染为全屏背景;无 UI 背景图时继续使用原封面模糊背景兜底。棋盘本身仍由正式拼图图生成,不能把 UI 背景当作拼图切块来源。 +4. 草稿编译阶段自动生成 UI 背景失败时只记录 warning,并保留草稿进入结果页;用户可在 UI Tab 重新生成,不因背景图上游波动阻断首图草稿主流程。 +5. `api-server` 负责读取参考图、拼接生成 prompt、调用 VectorEngine、下载并转存 OSS;SpacetimeDB 只通过 `save_puzzle_ui_background` procedure 保存结果,不做外部 I/O。 +6. 拼图运行态读取 `currentLevel.uiBackgroundImageSrc` 渲染为全屏背景;无 UI 背景图时继续使用原封面模糊背景兜底。棋盘本身仍由正式拼图图生成,不能把 UI 背景当作拼图切块来源。 ### 2026-05-12 草稿生成完成自动试玩补充 @@ -149,9 +150,9 @@ ## 验收 1. 从拼图创作入口只能看到作品名称、作品描述、画面描述和参考图上传,不出现 Agent 聊天输入、补齐设定、锚点问答。 -2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择。 +2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择、背景音乐生成和首关 UI 背景图生成。 3. 首图生成请求使用玩家画面描述作为 prompt;上传参考图时走图生图;作品详情页展示玩家作品描述。 4. 结果页包含“拼图关卡”“作品信息”“UI”“音乐”四个 Tab;关卡列表默认至少一关,支持新增、删除和进入关卡详情。 5. 关卡详情页支持生成或重新生成画面;已有正式图后显示吸底“关卡测试”入口。 6. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。 -7. UI Tab 可修改提示词并重新生成背景图;生成后运行态应显示 `uiBackgroundImageSrc`,且拼图棋盘区域和 UI 背景区域有明确边界。 +7. 草稿初次生成后首关默认带 `uiBackgroundImageSrc`;UI Tab 可修改提示词并重新生成背景图;生成后运行态应显示 `uiBackgroundImageSrc`,且拼图棋盘区域和 UI 背景区域有明确边界。 diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 7d38871f..9eb0038d 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -3862,12 +3862,6 @@ fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) -> }) .count(); view_count >= MATCH3D_ITEM_VIEW_COUNT - || asset - .image_object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some() } fn has_match3d_required_item_images( @@ -4952,7 +4946,7 @@ mod tests { } #[test] - fn match3d_required_item_images_require_object_keys() { + fn match3d_required_item_images_require_five_views() { let assets = vec![ Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), @@ -5024,6 +5018,47 @@ mod tests { ]; assert!(!has_match3d_required_item_images(&assets, 3)); + + let five_view_assets = (1..=3) + .map(|index| Match3DGeneratedItemAsset { + item_id: format!("match3d-item-{index}"), + item_name: format!("物品{index}"), + image_src: Some(format!( + "/generated-match3d-assets/s/p/items/i{index}/views/view-01.png" + )), + image_object_key: Some(format!( + "generated-match3d-assets/s/p/items/i{index}/views/view-01.png" + )), + image_views: (1..=MATCH3D_ITEM_VIEW_COUNT) + .map(|view_index| Match3DGeneratedItemImageView { + view_id: format!("view-{view_index:02}"), + view_index: view_index as u32, + image_src: Some(format!( + "/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" + )), + image_object_key: Some(format!( + "generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" + )), + }) + .collect(), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + sound_prompt: None, + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_music: None, + click_sound: None, + background_asset: None, + status: "image_ready".to_string(), + error: None, + }) + .collect::>(); + + assert!(has_match3d_required_item_images(&five_view_assets, 3)); } #[test] diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d/mod.rs index af39d09e..4c583dfd 100644 --- a/server-rs/crates/spacetime-module/src/match3d/mod.rs +++ b/server-rs/crates/spacetime-module/src/match3d/mod.rs @@ -1338,7 +1338,7 @@ fn count_ready_generated_item_types(value: Option<&str>) -> Result) -> Result= 5 || has_primary_image) + status_ready && view_count >= 5 }) .count()) } @@ -1893,6 +1881,68 @@ mod tests { ); } + #[test] + fn match3d_publish_ready_requires_five_image_views_per_item() { + let base_work = Match3DWorkProfileRow { + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: "session-1".to_string(), + author_display_name: "作者".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "水果主题".to_string(), + tags_json: "[\"水果\"]".to_string(), + cover_image_src: "/cover.png".to_string(), + cover_asset_id: String::new(), + clear_count: 8, + difficulty: 2, + config_json: to_json_string(&Match3DCreatorConfigSnapshot { + theme_text: "水果".to_string(), + reference_image_src: None, + clear_count: 8, + difficulty: 2, + asset_style_id: None, + asset_style_label: None, + asset_style_prompt: None, + generate_click_sound: false, + }), + publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), + play_count: 0, + updated_at: Timestamp::from_micros_since_unix_epoch(1), + published_at: None, + generated_item_assets_json: Some( + r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"},{"itemId":"match3d-item-2","itemName":"苹果","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"},{"imageSrc":"/v5.png"}],"status":"model_ready"},{"itemId":"match3d-item-3","itemName":"香蕉","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"}],"status":"image_ready"}]"# + .to_string(), + ), + }; + + let error = validate_publishable_work(&base_work).unwrap_err(); + assert!(error.contains("当前已有 0 种")); + + let ready_assets = (1..=3) + .map(|index| { + let views = (1..=5) + .map(|view_index| { + format!( + r#"{{"imageSrc":"/generated-match3d-assets/session/profile/items/i{index}/views/view-{view_index:02}.png"}}"# + ) + }) + .collect::>() + .join(","); + format!( + r#"{{"itemId":"match3d-item-{index}","itemName":"物品{index}","imageViews":[{views}],"status":"image_ready"}}"# + ) + }) + .collect::>() + .join(","); + let ready_work = Match3DWorkProfileRow { + generated_item_assets_json: Some(format!("[{ready_assets}]")), + ..base_work + }; + + assert!(validate_publishable_work(&ready_work).is_ok()); + } + #[test] fn match3d_compile_without_metadata_payload_preserves_existing_metadata() { let existing = Match3DWorkProfileRow { diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx index 9e6cc7b4..49fdd2b6 100644 --- a/src/components/match3d-result/Match3DResultView.test.tsx +++ b/src/components/match3d-result/Match3DResultView.test.tsx @@ -349,6 +349,39 @@ describe('Match3DResultView', () => { expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled(); }); + test('发布要求当前难度素材都具备五个视角', () => { + const generatedItemAssets = [ + createReadyGeneratedItemAsset(1), + createReadyGeneratedItemAsset(2), + { + ...createReadyGeneratedItemAsset(3), + imageViews: createReadyGeneratedItemAsset(3).imageViews?.slice(0, 4), + }, + ]; + + render( + {}} + onStartTestRun={() => {}} + />, + ); + + const publishButton = screen.getByRole('button', { name: '发布' }); + expect(publishButton).toHaveProperty('disabled', true); + fireEvent.click(publishButton); + expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '难度配置' })); + expect(screen.getByText('已生成物品种类')).toBeTruthy(); + expect(screen.getAllByText('2 种').length).toBeGreaterThan(0); + }); + test('发布前会先把当前 2D 多视角素材写回 profile', async () => { const generatedItemAssets = [ createReadyGeneratedItemAsset(1), @@ -776,6 +809,45 @@ describe('Match3DResultView', () => { ).toBe(true); }); + test('物品详情五视角预览不混入兼容首图', () => { + const generatedAsset = createReadyGeneratedItemAsset(1); + + render( + {}} + onStartTestRun={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '素材配置' })); + fireEvent.click( + screen.getByRole('button', { name: '打开物品1物品素材' }), + ); + + const imageSources = [...document.querySelectorAll('img')].map((image) => + image.getAttribute('src') ?? '', + ); + expect( + imageSources.some((source) => source.includes('legacy-primary.png')), + ).toBe(false); + expect( + imageSources.some((source) => source.includes('views/view-05.png')), + ).toBe(true); + expect(screen.getAllByText('5 视角').length).toBeGreaterThan(0); + }); + test('草稿阶段仅有切割图片时展示 2D 素材', () => { render( 0; } +function hasMatch3DGeneratedFiveViewImageSource( + asset: Match3DGeneratedItemAsset, +) { + return ( + (asset.imageViews ?? []).filter( + (view) => + Boolean(view.imageSrc?.trim()) || Boolean(view.imageObjectKey?.trim()), + ).length >= 5 + ); +} + function resolveMatch3DGeneratedImageViewSourceFromDraft( view: NonNullable[number], ) { return view.imageObjectKey?.trim() || view.imageSrc?.trim() || ''; } +function resolveMatch3DAssetDraftImageViewSources( + asset: Match3DItemAssetDraft, +) { + return [ + ...new Set( + (asset.imageViews ?? []) + .map(resolveMatch3DGeneratedImageViewSourceFromDraft) + .filter(Boolean), + ), + ]; +} + +function resolveMatch3DAssetDraftPreviewSources(asset: Match3DItemAssetDraft) { + const imageViewSources = resolveMatch3DAssetDraftImageViewSources(asset); + if (imageViewSources.length > 0) { + return imageViewSources.slice(0, 5); + } + const fallbackSource = asset.referenceImageSrc.trim(); + return fallbackSource ? [fallbackSource] : []; +} + function hasPersistableMatch3DGeneratedItemAsset( asset: Match3DGeneratedItemAsset, ) { @@ -1703,7 +1736,7 @@ function Match3DItemAssetListCard({ onDelete: () => void; }) { const pillClass = getMatch3DAssetStatusPillClass(asset.status); - const imageViews = asset.imageViews ?? []; + const previewSources = resolveMatch3DAssetDraftPreviewSources(asset); return (
- {Math.max(imageViews.length, asset.referenceImageSrc ? 1 : 0)} 视角 + {previewSources.length} 视角 2D素材 @@ -1783,33 +1816,25 @@ function Match3DItemAssetDetail({ onChange: (asset: Match3DItemAssetDraft) => void; onGenerateClickSound: (asset: Match3DItemAssetDraft) => void; }) { - const imageViews = asset.imageViews ?? []; + const previewSources = resolveMatch3DAssetDraftPreviewSources(asset); return (
- {[ - asset.referenceImageSrc, - ...imageViews - .map(resolveMatch3DGeneratedImageViewSourceFromDraft) - .filter(Boolean), - ] - .filter((source, index, list) => source && list.indexOf(source) === index) - .slice(0, 5) - .map((source, index) => ( -
-
- ))} + {previewSources.map((source, index) => ( +
+
+ ))}
diff --git a/src/services/match3dGeneratedModelCache.test.ts b/src/services/match3dGeneratedModelCache.test.ts index 56f85220..3fa29603 100644 --- a/src/services/match3dGeneratedModelCache.test.ts +++ b/src/services/match3dGeneratedModelCache.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { setStoredAccessToken, clearStoredAccessToken } from './apiClient'; import { clearMatch3DGeneratedModelBytesCache, + getMatch3DGeneratedImageViewSources, getMatch3DGeneratedModelAssetSources, preloadMatch3DGeneratedModelAssets, readMatch3DGeneratedModelBytes, @@ -113,4 +114,35 @@ describe('match3dGeneratedModelCache', () => { 'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb', ]); }); + + test('多视角图片源优先使用 imageViews,兼容首图只做兜底', () => { + const sources = getMatch3DGeneratedImageViewSources({ + itemId: 'match3d-item-1', + itemName: '草莓', + imageSrc: + '/generated-match3d-assets/session/profile/items/item-1/legacy-primary.png', + imageObjectKey: + 'generated-match3d-assets/session/profile/items/item-1/legacy-primary.png', + imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({ + viewId: `view-${String(viewIndex).padStart(2, '0')}`, + viewIndex, + imageSrc: `/generated-match3d-assets/session/profile/items/item-1/views/view-${String(viewIndex).padStart(2, '0')}.png`, + imageObjectKey: null, + })), + modelSrc: null, + modelObjectKey: null, + modelFileName: null, + taskUuid: null, + subscriptionKey: null, + status: 'image_ready', + error: null, + }); + + expect(sources).toHaveLength(5); + expect(sources[0]).toContain('views/view-01.png'); + expect(sources[4]).toContain('views/view-05.png'); + expect(sources.some((source) => source.includes('legacy-primary'))).toBe( + false, + ); + }); }); diff --git a/src/services/match3dGeneratedModelCache.ts b/src/services/match3dGeneratedModelCache.ts index 4354e546..b8b8bc9f 100644 --- a/src/services/match3dGeneratedModelCache.ts +++ b/src/services/match3dGeneratedModelCache.ts @@ -88,14 +88,17 @@ export function resolveMatch3DGeneratedImageViewSource( export function getMatch3DGeneratedImageViewSources( asset: Match3DGeneratedItemAsset, ) { - const sources = + const viewSources = asset.imageViews ?.map(resolveMatch3DGeneratedImageViewSource) .filter((source) => source.length > 0) ?? []; + if (viewSources.length > 0) { + return [...new Set(viewSources)]; + } const primarySource = normalizeMatch3DModelSource(asset.imageObjectKey) || normalizeMatch3DModelSource(asset.imageSrc); - return [...new Set(primarySource ? [primarySource, ...sources] : sources)]; + return primarySource ? [primarySource] : []; } export function resolveMatch3DGeneratedImageAssetSource(