This commit is contained in:
2026-05-14 13:40:50 +08:00
parent 5a55180b78
commit 2dc9d752e4
24 changed files with 1873 additions and 98 deletions

View File

@@ -30,6 +30,14 @@
- 验证:结果页试听和运行态 `<audio loop>``src` 为签名 URL 或公开 URL拼图/抓大鹅运行态首次局内交互后会再次尝试播放背景音乐;`npm run typecheck` 不报契约字段缺失,后端 run response 带 `backgroundMusic`
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`
## 抓大鹅背景音乐是作品级字段但暂存在首个物品素材
- 现象:抓大鹅草稿生成日志和 work detail 中已有背景音乐,但结果页 `素材配置 > 背景音乐` 显示“暂无音乐”,点击试玩后局内也不播放生成音乐。
- 原因:当前表结构没有作品级音频字段,背景音乐暂存在 `generatedItemAssets[]`。如果 action response 的 draft assets 缺音乐,前端又优先用它覆盖 work detail或音乐落在非首个素材而结果页只读 `assetDrafts[0].backgroundMusic`,就会丢掉已生成音乐。
- 处理:前端统一使用 `normalizeMatch3DGeneratedItemAssetsForRuntime` / `mergeMatch3DGeneratedItemAssetsForRuntime`:把任意素材上的 `backgroundMusic` 与音乐元信息迁移到首个素材清空其它素材上的作品级音乐字段action draft assets 与 work detail assets 按 `itemId` 合并保留详情里的音乐、UI 背景和点击音效。
- 验证:`npm run test -- src\services\match3dGeneratedModelCache.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`;平台推荐流定向跑 `RpgEntryFlowShell.agent.interaction.test.tsx` 中的 Match3D runtime assets 用例;`npm run typecheck`
- 关联:`src/services/match3dGeneratedModelCache.ts``src/components/match3d-result/Match3DResultView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 中文乱码与编码风险
- 现象:中文文案、注释、剧情或文档显示为乱码,或被改写成英文。
@@ -148,6 +156,14 @@
- 验证:`npm run test -- src/services/puzzle-runtime/puzzleLocalRuntime.test.ts src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 关联:`src/services/puzzle-runtime/puzzleLocalRuntime.ts``src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/puzzle-result/PuzzleResultView.tsx``docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`
## 拼图草稿生成后音乐/UI 又变空先查结果页回包合并
- 现象拼图草稿生成完成后音乐面板曲名有值但音频槽仍显示“暂无音乐”UI 仍展示默认预览;试玩进入局内也没有生成音乐或 UI 背景。
- 原因:结果页若已有本地 `generationStatus = generating` 编辑态,后端生成完成回包会走 `mergeDraftEditStateWithIncomingState(...)` 合并。该合并必须把生成候选图、正式图、`uiBackground*``backgroundMusic` 作为同一批生成资产处理;漏掉 `backgroundMusic` 时,随后自动保存会把空音乐写回 `levels_json`
- 处理:`PuzzleResultView` 合并生成完成回包时同步保留 `backgroundMusic`,并用回归测试覆盖 UI 预览、音乐试听和试玩 payload 都读取最新 `levels[]` 资产。
- 验证:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`,以及自动试玩入口测试 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial"`
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`
## 拼图草稿生成 180 秒后 502/504 先查 VectorEngine 超时与前端重试
- 现象:点击“生成拼图游戏草稿”后,`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 等待约 180 秒返回 `502 Bad Gateway``504 Gateway Timeout`;钱包流水里同一 session 可能出现连续两组 `puzzle_initial_image` 扣费后退款。

View File

@@ -37,6 +37,7 @@ GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false
APIMART_BASE_URL=
APIMART_API_KEY=
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai
VECTOR_ENGINE_API_KEY=

View File

@@ -22,6 +22,7 @@
5. 已发布卡片在描述下方显示三项公开指标:游玩数、改造数、点赞数。
6. 已发布卡片右上角显示分享 icon点击后复制作品分享文案不触发卡片打开。
7. 草稿卡片右上角继续显示删除 icon点击删除不触发卡片打开。
8. 卡片不显示最后修改时间;`updatedAt` 只用于作品列表排序。
## 公开指标重点展示补充
@@ -31,6 +32,7 @@
4. 若最新值高于缓存值,动画完成后在对应指标右下角展示红色向上箭头和本次上涨的具体数值,字号低于主数字,避免抢占主信息层级。
5. 若没有缓存值、缓存值不低于最新值或作品仍是草稿,则直接显示最新值,不展示上涨标记。
6. 每张作品卡片继续使用作品封面作为整卡背景,封面需要有透明度和渐变遮罩,确保标题、描述和指标在亮色与暗色主题下都清晰可读。
7. 作品列表按 `updatedAt` 倒序排列;前端排序需要兼容 ISO 时间和 Rust 后端常用的 `seconds.microsZ` 时间文本。
## 移动端布局规则

View File

@@ -68,6 +68,9 @@
- 默认显示全部作品列表,保留草稿/已发布筛选。
- 入口、删除、分享、领取拼图激励等行为全部复用现有 `CustomWorldCreationHub` 的作品卡逻辑。
- 一级底部 Tab 文案为“草稿”,内部仍可按草稿与已发布筛选。
- 作品列表必须按最后修改时间倒序排列;排序使用后端 `updatedAt`,前端需要兼容 ISO 字符串和 `seconds.microsZ` 两种时间文本。
- 每张作品卡以作品封面图铺满整卡背景,并叠加遮罩保证标题和描述可读;缺封面时才使用现有兜底底图。
- 草稿页卡片不展示“最后修改时间”“更新于”或原始 `updatedAt` 文本,时间只参与排序。
## 6. 我的页玩过列表
@@ -125,3 +128,4 @@
- 底部一级“草稿”Tab 在存在未查看新完成草稿时展示红点用户点击查看带红点的作品后该作品红点消失。若草稿页已无任何带红点作品底部“草稿”Tab 红点同步消失。
- 生成完成时如果用户仍停留在对应生成过程页,可自动进入结果页;如果用户已经回到创作中心或其它功能页,不打断当前操作。
- 创作 Tab 的模板入口只允许被模板自身的开放状态禁用;某个草稿后台生成中时,不得用该玩法的 busy 状态禁用其它模板入口、同模板再次创建入口或阻止用户继续创建新作品。
- 同模板再次点击生成时必须创建新的草稿生成任务,不得因为当前玩法已有后台生成 session 就跳回上一条草稿的生成过程页;查看上一条生成进度只能从草稿 Tab 的对应作品卡进入。

View File

@@ -43,4 +43,6 @@
1. 创作中心三类作品仍在同一个网格展示。
2. 草稿 / 已发布筛选计数统一从 `CreationWorkShelfItem.status` 读取。
3. 卡片渲染不再直接判断 `publicationStatus` 或不同 works schema 的标题字段。
4. 现有创作中心交互测试通过。
4. 统一货架按 `updatedAt` 倒序排序,兼容 ISO 字符串和 `seconds.microsZ` 后端时间文本。
5. 作品卡片以 `coverImageSrc` 作为整卡背景;卡片不展示最后修改时间,`updatedAt` 只参与排序。
6. 现有创作中心交互测试通过。

View File

@@ -142,6 +142,8 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard
结果页点击 `试玩` 时,前端必须把当前结果页可见的 `generatedItemAssets` 带入运行态启动入参。`PUT /api/runtime/match3d/works/{profileId}` 若因为并发或旧快照返回了缺少素材的 profile`Match3DResultView` 需要把当前 draft / profile 的素材重新合并到运行态 profile并在启动试玩前调用生成素材保存接口把当前可见的 `generatedItemAssets` 写回作品 profile不能只在内存里把素材补到 `onStartTestRun(profile)`。发布同理必须先落库当前素材,再调用 `publish_match3d_work`,否则公开推荐流和正式运行态只能读到旧 profile 快照。结果页顶部返回按钮固定回到平台创作页,不再回到抓大鹅专属内嵌入口表单;需要修改题材时由用户在创作页重新选择或从草稿继续进入。若历史草稿同时存在旧 `draft.generatedItemAssets` 和较新的 `profile.generatedItemAssets`,同 `itemId` 下以 profile 中已有的 `imageViews[]``imageSrc``imageObjectKey``backgroundMusic``backgroundAsset` 补齐 draft不能让旧 draft 把素材覆盖成空列表。`PlatformEntryFlowShellImpl` 在渲染 `match3d-runtime` 时按 `run.profileId` 优先使用当前 `match3dRuntimeProfile / match3dProfile.generatedItemAssets`,只有 profileId 不匹配时才读取 `selectedPublicWorkDetail.generatedItemAssets`;即使当前 profile 暂时没有物品图片,也不能把同 profile 的已有 `generatedItemAssets` 覆盖为空数组。推荐流内嵌正式运行态也必须走同一解析器;当推荐卡片摘要缺少物品图片素材时,启动前补读 `getMatch3DWorkDetail(profileId)`,把详情里的生成图片、背景音乐和 UI 素材写入 `match3dRuntimeProfile` 后再传给运行态。这样可以避免从公开详情页残留状态或推荐卡片旧摘要进入试玩 / 正式游戏时,把已生成草稿的 2D 素材、音乐或 UI 覆盖成空列表。
2026-05-14 补充:`backgroundMusic` 虽然暂存在 `generatedItemAssets[]` 中,但语义上是作品级音乐。前端读取、保存、试玩、推荐流和运行态入口都必须先通过统一归一化逻辑把任意素材上的 `backgroundMusic/backgroundMusicTitle/backgroundMusicStyle/backgroundMusicPrompt` 迁移到首个素材,并清空其它素材上的作品级音乐字段,避免 `素材配置 > 背景音乐` 只读首项时显示“暂无音乐”,也避免 action response 中缺音乐的 draft assets 覆盖 work detail 中已经持久化的音乐。`match3d_compile_draft` action 完成后,如果同时拿到 `response.session.draft.generatedItemAssets``getMatch3DWorkDetail(profileId).item.generatedItemAssets`,必须以同 `itemId` 合并保留详情里的背景音乐、UI 背景和点击音效,再进入结果页或试玩。
历史草稿若仍保存 `status = model_ready``modelSrc``modelObjectKey`,仅作为旧版本兼容读取,不再参与新素材生产。历史外部模型链接转存接口只用于清理旧数据,不能被新草稿生成、批量新增或结果页普通编辑入口调用。
生成完成后自动进入试玩依赖 `selectionStageRef.current === 'match3d-generating'` 的同步判断。执行 `match3d_compile_draft` 前切到生成页时,必须同时写 `selectionStageRef.current = 'match3d-generating'``setSelectionStage('match3d-generating')`;只调用 React state 会让 action 很快返回时读到旧 stage表现为生成页已经 100% 但不进入试玩或结果页。拼图、大鱼吃小鱼、方洞挑战等同类生成页也遵循同一规则。
@@ -163,6 +165,8 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard
草稿 Tab -> getMatch3DWorkDetail(profileId) -> Match3DResultView(profile.generatedItemAssets)
```
若草稿卡已经带有前端生成中标记,即使作品列表已经刷新成真实 `match3d_work_profile` 草稿行,点击该卡也必须优先回到 `match3d-generating` 生成过程页,并按 `sourceSessionId` 重新读取当前 session不能因为 `getSession` 返回了带 `draft` 的快照就直接进入结果页。若后台生成已经收口为 ready 且草稿卡仍有未读红点,首次点击必须消费红点并直接启动抓大鹅试玩,返回后展示 `Match3DResultView`;红点已读后的后续点击才按常规结果页恢复路径进入 `Match3DResultView`。因此点击恢复优先级固定为:未读 ready 红点自动试玩 > 仍在 generating 的本地 / 后台任务回生成页 > 普通草稿结果页。
因此 `map_match3d_work_summary_response` / `map_match3d_work_profile_response` 需要从 work profile snapshot 反序列化 `generated_item_assets_json` 并输出 `generatedItemAssets` 与顶层背景字段。前端 `Match3DResultView` 的读取顺序为:有 `draft.generatedItemAssets` 时先用 draft 保留本次生成顺序和图片;同 `itemId``profile.generatedItemAssets` 中已有 `imageViews[]``imageSrc/imageObjectKey` 时,用 profile 图片字段补齐 draft背景资产同样必须从 profile 或 draft 的首个 `backgroundAsset` 保留到保存 payload从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认素材占位。
结果页 `作品信息` Tab 字段命名对齐拼图草稿:

View File

@@ -114,6 +114,7 @@
5. `api-server` 负责拼接生成 prompt、调用 VectorEngine、下载并转存 OSSSpacetimeDB 只通过 `save_puzzle_ui_background` procedure 保存结果,不做外部 I/O。
6. 拼图运行态读取 `currentLevel.uiBackgroundImageSrc` 渲染为全屏背景;无 UI 背景图时继续使用原封面模糊背景兜底。棋盘本身仍由正式拼图图生成,不能把 UI 背景当作拼图切块来源。
7. 生成完成后的自动试玩和结果页“试玩”走前端本地运行态兜底时,`startLocalPuzzleRun` 也必须从 `PuzzleWorkSummary.levels[]` 复制 `uiBackgroundImageSrc``backgroundMusic``currentLevel`;不得只带 `coverImageSrc`,否则草稿结果页有背景但试玩局内空白。
8. 结果页在本地关卡处于 `generationStatus = generating` 时合并后端生成完成回包,必须同时合并 `uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey``backgroundMusic`。若只合并图片字段而漏掉音乐字段,随后自动保存会把空 `backgroundMusic` 写回 `puzzle_work_profile.levels_json`,导致素材配置显示“暂无音乐”并让自动试玩/试玩局内无音乐。
### 2026-05-12 草稿生成完成自动试玩补充

View File

@@ -93,6 +93,7 @@
2. 抓大鹅结果页已使用同一换签口径,后续新增音频试听入口必须复用该模式。
3. 拼图和抓大鹅运行态在开局时会尝试自动播放背景音乐;若浏览器因自动播放策略拒绝,玩家首次按下拼图块或点击抓大鹅物品时必须再次调用同一个背景音乐播放函数,避免草稿音乐已经传入运行态但局内始终无声。
4. 播放失败仍只做静默兜底,不弹出规则说明或阻断局内交互。
5. 拼图结果页合并后端生成完成回包时,若本地首关仍处于 `generationStatus = generating`,必须把 `backgroundMusic` 与候选图、正式图、UI 背景一起合并进编辑态;否则音乐面板会继续显示“暂无音乐”,后续自动保存还会把空音乐写回 profile。
## 5. 验收

View File

@@ -101,6 +101,7 @@ pub struct AppConfig {
pub dashscope_image_request_timeout_ms: u64,
pub apimart_base_url: String,
pub apimart_api_key: Option<String>,
pub apimart_image_request_timeout_ms: u64,
pub vector_engine_base_url: String,
pub vector_engine_api_key: Option<String>,
pub vector_engine_image_request_timeout_ms: u64,
@@ -219,6 +220,7 @@ impl Default for AppConfig {
dashscope_image_request_timeout_ms: 150_000,
apimart_base_url: String::new(),
apimart_api_key: None,
apimart_image_request_timeout_ms: 180_000,
vector_engine_base_url: String::new(),
vector_engine_api_key: None,
vector_engine_image_request_timeout_ms: 180_000,
@@ -605,6 +607,12 @@ impl AppConfig {
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
if let Some(apimart_image_request_timeout_ms) =
read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
}
if let Some(vector_engine_base_url) = read_first_non_empty_env(&["VECTOR_ENGINE_BASE_URL"])
{
config.vector_engine_base_url = vector_engine_base_url;
@@ -991,6 +999,7 @@ mod tests {
std::env::remove_var("GENARRATIVE_LLM_BASE_URL");
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");
@@ -1006,6 +1015,7 @@ mod tests {
);
std::env::set_var("GENARRATIVE_LLM_MODEL", "internal-text-model");
std::env::set_var("APIMART_BASE_URL", "https://responses.internal.example/v1");
std::env::set_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS", "190000");
std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.internal.example");
std::env::set_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", "210000");
std::env::set_var("HYPER3D_BASE_URL", "https://model.internal.example/api/v2");
@@ -1027,6 +1037,7 @@ mod tests {
config.apimart_base_url,
"https://responses.internal.example/v1"
);
assert_eq!(config.apimart_image_request_timeout_ms, 190_000);
assert_eq!(
config.vector_engine_base_url,
"https://vector.internal.example"
@@ -1050,6 +1061,7 @@ mod tests {
std::env::remove_var("GENARRATIVE_LLM_BASE_URL");
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");

View File

@@ -5,6 +5,8 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::time::sleep;
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
@@ -102,6 +104,9 @@ const MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD: i32 = 36;
const MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE: f32 = 0.34;
const MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18;
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 25;
const MATCH3D_MATERIAL_APIMART_MODEL: &str = "gemini-3.1-flash-image-preview";
const MATCH3D_MATERIAL_APIMART_SIZE: &str = "1:1";
const MATCH3D_MATERIAL_APIMART_RESOLUTION: &str = "1K";
const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000;
const MATCH3D_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000;
const MATCH3D_LEGACY_MODEL_MAX_BYTES: usize = 120 * 1024 * 1024;
@@ -3766,6 +3771,12 @@ struct Match3DMaterialSheet {
image: DownloadedOpenAiImage,
}
struct Match3DApimartImageSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct Match3DSlicedItemImage {
bytes: Vec<u8>,
}
@@ -4728,24 +4739,21 @@ async fn generate_match3d_material_sheet(
config: &Match3DConfigJson,
item_names: &[String],
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let settings = require_match3d_apimart_image_settings(state)?;
let http_client = build_match3d_apimart_image_http_client(&settings)?;
let prompt = build_match3d_material_sheet_prompt(config, item_names);
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
let generated = create_openai_image_generation(
let generated = create_match3d_apimart_nanobanana_image_generation(
&http_client,
&settings,
prompt.as_str(),
Some(negative_prompt.as_str()),
"1:1",
1,
&[],
negative_prompt.as_str(),
"抓大鹅素材图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"provider": "apimart",
"message": "抓大鹅素材图生成失败:未返回图片",
}))
})?;
@@ -4756,6 +4764,505 @@ async fn generate_match3d_material_sheet(
})
}
fn require_match3d_apimart_image_settings(
state: &AppState,
) -> Result<Match3DApimartImageSettings, AppError> {
let base_url = state.config.apimart_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.apimart_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_API_KEY 未配置",
}))
})?;
Ok(Match3DApimartImageSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1),
})
}
fn build_match3d_apimart_image_http_client(
settings: &Match3DApimartImageSettings,
) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "apimart",
"message": format!("构造抓大鹅 APIMart 图片生成 HTTP 客户端失败:{error}"),
}))
})
}
async fn create_match3d_apimart_nanobanana_image_generation(
http_client: &reqwest::Client,
settings: &Match3DApimartImageSettings,
prompt: &str,
negative_prompt: &str,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let request_body = build_match3d_apimart_nanobanana_image_request_body(
prompt,
negative_prompt,
MATCH3D_MATERIAL_APIMART_SIZE,
);
let response = http_client
.post(format!("{}/images/generations", settings.base_url))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
.await
.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:创建 APIMart nanobanana 图片生成任务失败:{error}"
))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:读取 APIMart nanobanana 图片生成响应失败:{error}"
))
})?;
if !status.is_success() {
return Err(map_match3d_apimart_image_upstream_error(
status,
response_text.as_str(),
failure_context,
));
}
let payload = parse_match3d_json_payload(
response_text.as_str(),
"解析抓大鹅 APIMart nanobanana 图片生成响应失败",
"apimart",
)?;
let image_urls = extract_match3d_image_urls(&payload);
if !image_urls.is_empty() {
return download_match3d_images_from_urls(
http_client,
format!("apimart-nanobanana-{}", current_utc_micros()),
image_urls,
1,
"apimart",
)
.await;
}
let b64_images = extract_match3d_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(match3d_images_from_base64(
format!("apimart-nanobanana-{}", current_utc_micros()),
b64_images,
1,
));
}
let task_id = extract_match3d_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅 APIMart nanobanana 图片生成未返回 task_id 或图片地址",
}))
})?;
wait_match3d_apimart_generated_images(http_client, settings, task_id.as_str(), failure_context)
.await
}
fn build_match3d_apimart_nanobanana_image_request_body(
prompt: &str,
negative_prompt: &str,
size: &str,
) -> Value {
Value::Object(serde_json::Map::from_iter([
(
"model".to_string(),
Value::String(MATCH3D_MATERIAL_APIMART_MODEL.to_string()),
),
(
"prompt".to_string(),
Value::String(build_match3d_apimart_prompt(prompt, negative_prompt)),
),
("n".to_string(), json!(1)),
("official_fallback".to_string(), Value::Bool(true)),
("size".to_string(), Value::String(size.to_string())),
(
"resolution".to_string(),
Value::String(MATCH3D_MATERIAL_APIMART_RESOLUTION.to_string()),
),
]))
}
fn build_match3d_apimart_prompt(prompt: &str, negative_prompt: &str) -> String {
let prompt = prompt.trim();
let negative_prompt = negative_prompt.trim();
if negative_prompt.is_empty() {
return prompt.to_string();
}
format!("{prompt}\n避免:{negative_prompt}")
}
async fn wait_match3d_apimart_generated_images(
http_client: &reqwest::Client,
settings: &Match3DApimartImageSettings,
task_id: &str,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let deadline = std::time::Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while std::time::Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:查询 APIMart nanobanana 图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_match3d_apimart_image_request_error(format!(
"{failure_context}:读取 APIMart nanobanana 图片生成任务响应失败:{error}"
))
})?;
if !poll_status.is_success() {
return Err(map_match3d_apimart_image_upstream_error(
poll_status,
poll_text.as_str(),
failure_context,
));
}
let payload = parse_match3d_json_payload(
poll_text.as_str(),
"解析抓大鹅 APIMart nanobanana 图片生成任务响应失败",
"apimart",
)?;
let task_status = find_first_match3d_string_by_key(&payload, "status")
.or_else(|| find_first_match3d_string_by_key(&payload, "task_status"))
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(task_status.as_str(), "completed" | "succeeded" | "success") {
let image_urls = extract_match3d_image_urls(&payload);
if !image_urls.is_empty() {
return download_match3d_images_from_urls(
http_client,
task_id.to_string(),
image_urls,
1,
"apimart",
)
.await;
}
let b64_images = extract_match3d_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(match3d_images_from_base64(task_id.to_string(), b64_images, 1));
}
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅 APIMart nanobanana 图片生成成功但未返回图片",
})),
);
}
if matches!(
task_status.as_str(),
"failed" | "error" | "canceled" | "cancelled"
) {
return Err(map_match3d_apimart_image_upstream_error(
poll_status,
poll_text.as_str(),
failure_context,
));
}
sleep(Duration::from_secs(3)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": "抓大鹅 APIMart nanobanana 图片生成超时或未返回图片",
})),
)
}
async fn download_match3d_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
image_urls: Vec<String>,
candidate_count: u32,
provider: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
{
images.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
}
Ok(OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
})
}
async fn download_match3d_remote_image(
http_client: &reqwest::Client,
image_url: &str,
provider: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("下载抓大鹅生成图片失败:{error}"),
}))
})?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
}))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": "下载抓大鹅生成图片失败",
"status": status.as_u16(),
})),
);
}
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
Ok(DownloadedOpenAiImage {
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes: body.to_vec(),
})
}
fn match3d_images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> OpenAiGeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
.collect();
OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
}
}
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
Some(DownloadedOpenAiImage {
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
fn parse_match3d_json_payload(
raw_text: &str,
failure_context: &str,
provider: &str,
) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("{failure_context}{error}"),
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
}))
})
}
fn extract_match3d_task_id(payload: &Value) -> Option<String> {
find_first_match3d_string_by_key(payload, "task_id")
.or_else(|| find_first_match3d_string_by_key(payload, "id"))
}
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_match3d_strings_by_key(payload, "url", &mut urls);
collect_match3d_strings_by_key(payload, "image", &mut urls);
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
values
}
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_match3d_strings_by_key(payload, target_key, &mut results);
results.into_iter().next()
}
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
match payload {
Value::Array(entries) => {
for entry in entries {
collect_match3d_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key {
match nested_value {
Value::String(text) => {
let text = text.trim();
if !text.is_empty() {
results.push(text.to_string());
}
}
Value::Array(entries) => {
for entry in entries {
if let Some(text) = entry
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
}
}
}
_ => {}
}
}
collect_match3d_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn map_match3d_apimart_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": message,
}))
}
fn map_match3d_apimart_image_upstream_error(
upstream_status: reqwest::StatusCode,
raw_text: &str,
fallback_message: &str,
) -> AppError {
let message = parse_match3d_api_error_message(raw_text, fallback_message);
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
tracing::warn!(
provider = "apimart",
upstream_status = upstream_status.as_u16(),
message = %message,
raw_excerpt = %raw_excerpt,
"抓大鹅 APIMart nanobanana 图片生成上游请求失败"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
}))
}
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
for key in ["message", "code"] {
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
return if key == "message" {
value
} else {
format!("{fallback_message}{value}")
};
}
}
}
trimmed.to_string()
}
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
raw_text.chars().take(max_chars).collect()
}
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/png");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/png".to_string(),
}
}
fn match3d_mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/jpeg" | "image/jpg" => "jpg",
_ => "png",
}
}
async fn download_match3d_legacy_model(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
) -> Result<Match3DDownloadedModel, AppError> {

View File

@@ -257,3 +257,70 @@ test('creation hub published work spans full mobile row', () => {
expect(html).toContain('col-span-2 sm:col-span-1');
expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2');
});
test('creation hub draft cards use cover background and hide updated time', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
puzzleItems={[
{
workId: 'puzzle:old-draft',
profileId: 'puzzle-profile-old',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '旧草稿',
workDescription: '先前修改的拼图草稿。',
levelName: '旧草稿',
summary: '先前修改的拼图草稿。',
themeTags: [],
coverImageSrc: '/covers/old-draft.webp',
publicationStatus: 'draft',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: null,
publishReady: false,
},
{
workId: 'puzzle:new-draft',
profileId: 'puzzle-profile-new',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '新草稿',
workDescription: '最近修改的拼图草稿。',
levelName: '新草稿',
summary: '最近修改的拼图草稿。',
themeTags: [],
coverImageSrc: '/covers/new-draft.webp',
publicationStatus: 'draft',
updatedAt: '1778457601.234567Z',
publishedAt: null,
publishReady: false,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
/>,
);
const newerIndex = html.indexOf('新草稿');
const olderIndex = html.indexOf('旧草稿');
expect(newerIndex).toBeGreaterThanOrEqual(0);
expect(olderIndex).toBeGreaterThanOrEqual(0);
expect(newerIndex).toBeLessThan(olderIndex);
expect(html).toContain(
'class="absolute inset-0 h-full w-full object-cover" src="/covers/new-draft.webp"',
);
expect(html).toContain('src="/covers/new-draft.webp"');
expect(html).not.toContain('1778457601.234567Z');
expect(html).not.toContain('2026-05-07');
expect(html).not.toContain('更新于');
expect(html).not.toContain('最后修改');
});

View File

@@ -1,7 +1,10 @@
import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { buildCreationWorkShelfItems } from './creationWorkShelf';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
} from './creationWorkShelf';
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
const items = buildCreationWorkShelfItems({
@@ -141,3 +144,54 @@ test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
expect(items[1]?.status).toBe('draft');
expect(items[1]?.publicWorkCode).toBeNull();
});
test('buildCreationWorkShelfItems sorts works by latest updatedAt across timestamp formats', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:older',
profileId: 'puzzle-profile-older',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '旧草稿',
summary: '较早修改。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: null,
publishReady: false,
},
{
workId: 'puzzle:newer',
profileId: 'puzzle-profile-newer',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '新草稿',
summary: '较晚修改。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '1778457601.234567Z',
publishedAt: null,
publishReady: false,
},
],
});
expect(items.map((item) => item.id)).toEqual([
'puzzle:newer',
'puzzle:older',
]);
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,
);
expect(getCreationWorkShelfItemTime('2026-05-07T00:00:00.000Z')).toBe(
new Date('2026-05-07T00:00:00.000Z').getTime(),
);
});

View File

@@ -240,7 +240,8 @@ export function buildCreationWorkShelfItems(params: {
})
.sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
getCreationWorkShelfItemTime(right.updatedAt) -
getCreationWorkShelfItemTime(left.updatedAt),
);
}
@@ -731,7 +732,25 @@ function buildStatusBadge(
};
}
function getShelfItemTime(value: string) {
const timestamp = new Date(value).getTime();
export function getCreationWorkShelfItemTime(value: string) {
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
if (absoluteTimestamp >= 1_000_000_000_000_000) {
return rawTimestamp / 1000;
}
if (absoluteTimestamp >= 1_000_000_000_000) {
return rawTimestamp;
}
if (absoluteTimestamp >= 1_000_000_000) {
return rawTimestamp * 1000;
}
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}

View File

@@ -52,42 +52,42 @@ const MATCH3D_ASSET_STYLE_OPTIONS = [
label: '扁平图标',
imageSrc: '/match3d-style-references/flat-icon.png',
prompt:
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
'干净扁平的2D游戏道具图标风格正面视角色块清楚边缘硬朗。',
},
{
id: 'cel-cartoon',
label: '赛璐璐卡通',
imageSrc: '/match3d-style-references/cel-cartoon.png',
prompt:
'明亮赛璐璐卡通 2D 游戏道具风格,清晰线稿,硬边阴影,饱和配色,轮廓醒目。',
'明亮赛璐璐卡通2D游戏道具风格清晰线稿硬边阴影饱和配色轮廓醒目。',
},
{
id: 'pixel-retro',
label: '像素复古',
label: '像素',
imageSrc: '/match3d-style-references/pixel-retro.png',
prompt:
'真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。',
'像素2D游戏道具sprite风格',
},
{
id: 'watercolor',
label: '手绘水彩',
imageSrc: '/match3d-style-references/watercolor.png',
prompt:
'手绘水彩 2D 道具素材风格,柔和纸张纹理,透明叠色,边缘轻微晕染,主体仍保持清楚可读。',
'手绘水彩2D道具素材风格',
},
{
id: 'sticker-outline',
label: '贴纸描边',
imageSrc: '/match3d-style-references/sticker-outline.png',
prompt:
'贴纸描边 2D 游戏道具素材风格,粗白边与深色外轮廓,柔和投影,色彩活泼,适合休闲消除游戏。',
'贴纸描边2D游戏道具素材风格粗白边与深色外轮廓',
},
{
id: 'painterly-icon',
label: '厚涂图标',
imageSrc: '/match3d-style-references/painterly-icon.png',
prompt:
'厚涂 2D 游戏道具图标风格,笔触细腻,体积光影明确,中心构图,保持图标级清晰剪影。',
'厚涂2D游戏道具图标风格笔触细腻体积光影明确中心构图保持图标级清晰剪影。',
},
{
id: 'custom',

View File

@@ -1414,4 +1414,73 @@ describe('Match3DResultView', () => {
);
});
});
test('背景音乐在非首个素材时仍显示并进入试玩 profile', async () => {
const onStartTestRun = vi.fn();
const generatedItemAssets = [
createReadyGeneratedItemAsset(1),
{
...createReadyGeneratedItemAsset(2),
backgroundMusicTitle: '漂浮船歌',
backgroundMusicStyle: '轻快, 愉悦, 现代',
backgroundMusicPrompt: '',
backgroundMusic: {
taskId: 'music-task-2',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-2',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/floating-song.mp3',
prompt: '',
title: '漂浮船歌',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
];
const profile = createProfile({ generatedItemAssets });
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: createProfile({ generatedItemAssets: [] }),
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({ generatedItemAssets }),
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
await waitFor(() => {
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/floating-song.mp3',
);
});
expect(screen.queryByText('暂无音乐')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
generatedItemAssets: expect.arrayContaining([
expect.objectContaining({
itemId: 'match3d-item-1',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-match3d-assets/audio/floating-song.mp3',
}),
}),
]),
}),
expect.objectContaining({ itemTypeCountOverride: 2 }),
);
});
});
});

View File

@@ -49,6 +49,8 @@ import {
} from '../../services/match3d-works';
import {
getMatch3DGeneratedImageViewSources,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
resolveMatch3DGeneratedImageAssetSource,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
@@ -768,7 +770,7 @@ function createGeneratedAssetsFromDrafts(
asset.backgroundMusicStyle ?? existing?.backgroundMusicStyle ?? null,
backgroundMusicPrompt:
asset.backgroundMusicPrompt ?? existing?.backgroundMusicPrompt ?? null,
backgroundMusic: asset.backgroundMusic,
backgroundMusic: asset.backgroundMusic ?? existing?.backgroundMusic ?? null,
clickSound: asset.clickSound,
backgroundAsset:
asset.backgroundAsset ??
@@ -1118,27 +1120,23 @@ function resolveMatch3DResultGeneratedItemAssets(
const profileAssets = profile.generatedItemAssets ?? [];
const draftAssets = draft?.generatedItemAssets ?? [];
if (draftAssets.length <= 0) {
return profileAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (profileAssets.length <= 0) {
return draftAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(draftAssets);
}
const profileAssetsById = new Map(
profileAssets.map((asset) => [asset.itemId, asset]),
return mergeMatch3DGeneratedItemAssetsForRuntime(
draftAssets.map((draftAsset) => {
const profileAsset = profileAssets.find(
(asset) => asset.itemId === draftAsset.itemId,
);
return profileAsset
? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
: draftAsset;
}),
profileAssets,
);
const mergedAssets = draftAssets.map((draftAsset) => {
const profileAsset = profileAssetsById.get(draftAsset.itemId);
return profileAsset
? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
: draftAsset;
});
for (const profileAsset of profileAssets) {
if (!mergedAssets.some((asset) => asset.itemId === profileAsset.itemId)) {
mergedAssets.push(profileAsset);
}
}
return mergedAssets;
}
function attachMatch3DGeneratedItemAssets(
@@ -1152,7 +1150,8 @@ function attachMatch3DGeneratedItemAssets(
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
return {
...profile,
generatedItemAssets: [...generatedItemAssets],
generatedItemAssets:
normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
};
}
@@ -1177,10 +1176,12 @@ function buildPersistableGeneratedItemAssets(
return [];
}
return createGeneratedAssetsFromDrafts(
assetDrafts,
generatedItemAssets,
).filter(hasPersistableMatch3DGeneratedItemAsset);
return normalizeMatch3DGeneratedItemAssetsForRuntime(
createGeneratedAssetsFromDrafts(
assetDrafts,
generatedItemAssets,
).filter(hasPersistableMatch3DGeneratedItemAsset),
);
}
function Match3DResultHeader({

View File

@@ -78,6 +78,7 @@ afterEach(() => {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__;
vi.restoreAllMocks();
});
function renderRuntime(
@@ -475,6 +476,74 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
});
});
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {
const run = startLocalMatch3DRun(3);
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = String(input);
const signedUrl = url.includes('legacyPublicPath')
? 'https://oss.example.com/match3d-music.mp3'
: 'https://oss.example.com/match3d-view.png';
return Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
});
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/match3d/strawberry.png',
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: '/match3d/apple.png',
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/music.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
];
renderRuntime(run, generatedItemAssets);
await waitFor(() => {
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://oss.example.com/match3d-music.mp3',
);
});
await waitFor(() => expect(playSpy).toHaveBeenCalled());
});
test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
const smallRun = startLocalMatch3DRun(12);
const hardRun = startLocalMatch3DRun(20);

View File

@@ -29,6 +29,7 @@ import {
} from '../../services/assetReadUrlService';
import {
getMatch3DGeneratedImageViewSources,
normalizeMatch3DGeneratedItemAssetsForRuntime,
} from '../../services/match3dGeneratedModelCache';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
@@ -481,6 +482,10 @@ export function Match3DRuntimeShell({
useState('');
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const runtimeGeneratedItemAssets = useMemo(
() => normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
[generatedItemAssets],
);
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
@@ -559,7 +564,7 @@ export function Match3DRuntimeShell({
const backgroundAssetSrc =
backgroundImageSrc?.trim() ||
generatedItemAssets
runtimeGeneratedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.imageSrc?.trim() ||
@@ -569,7 +574,7 @@ export function Match3DRuntimeShell({
.find(Boolean) ||
'';
const containerAssetSrc =
generatedItemAssets
runtimeGeneratedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
@@ -578,8 +583,8 @@ export function Match3DRuntimeShell({
)
.find(Boolean) || '';
const imageSourcesByType = useMemo(
() => buildMatch3DImageSourcesByType(run, generatedItemAssets),
[generatedItemAssets, run],
() => buildMatch3DImageSourcesByType(run, runtimeGeneratedItemAssets),
[runtimeGeneratedItemAssets, run],
);
const imageSourceSignature = useMemo(
() => buildMatch3DImageSourceSignature(imageSourcesByType),
@@ -597,7 +602,7 @@ export function Match3DRuntimeShell({
[imageSourcesByType, resolvedImageSources],
);
const backgroundMusicSrc =
generatedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
runtimeGeneratedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
?.backgroundMusic?.audioSrc ?? null;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
@@ -605,7 +610,7 @@ export function Match3DRuntimeShell({
if (!run) {
return new Map<string, string>();
}
const readyAssets = generatedItemAssets.filter(
const readyAssets = runtimeGeneratedItemAssets.filter(
(asset) => asset.clickSound?.audioSrc,
);
const sortedTypes = [
@@ -617,7 +622,7 @@ export function Match3DRuntimeShell({
return src ? [[typeId, src] as const] : [];
}),
);
}, [generatedItemAssets, run]);
}, [runtimeGeneratedItemAssets, run]);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
@@ -879,6 +884,7 @@ export function Match3DRuntimeShell({
src={resolvedBackgroundMusicSrc}
loop
preload="auto"
aria-label="抓大鹅背景音乐"
/>
) : null}
<div

View File

@@ -164,6 +164,8 @@ import {
} from '../../services/match3d-works';
import {
hasMatch3DGeneratedImageAsset,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
preloadMatch3DGeneratedRuntimeAssets,
} from '../../services/match3dGeneratedModelCache';
import {
@@ -643,7 +645,9 @@ function mapPublicWorkDetailToMatch3DWork(
entry.generatedItemAssets
?.map((asset) => asset.backgroundAsset ?? null)
.find(Boolean) ?? null,
generatedItemAssets: entry.generatedItemAssets ?? [],
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
entry.generatedItemAssets ?? [],
),
};
}
@@ -678,7 +682,9 @@ function buildMatch3DProfileFromSession(
backgroundImageSrc: draft.backgroundImageSrc ?? null,
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
generatedItemAssets: draft.generatedItemAssets,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
draft.generatedItemAssets,
),
};
}
@@ -702,7 +708,7 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
if (runProfileId && profile?.profileId === runProfileId) {
if (hasMatch3DRuntimeAsset(profileAssets)) {
return profileAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
@@ -711,11 +717,14 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
publicWorkDetail.profileId === runProfileId
) {
return hasMatch3DRuntimeAsset(publicDetailAssets)
? publicDetailAssets
: profileAssets;
? mergeMatch3DGeneratedItemAssetsForRuntime(
publicDetailAssets,
profileAssets,
)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return profileAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
@@ -724,13 +733,15 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return publicDetailAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets);
}
if (hasMatch3DRuntimeAsset(profileAssets)) {
return profileAssets;
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return publicDetailAssets.length > 0 ? publicDetailAssets : profileAssets;
return publicDetailAssets.length > 0
? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
function resolveActiveMatch3DRuntimeProfile(
@@ -2551,6 +2562,23 @@ export function PlatformEntryFlowShellImpl({
activePuzzleGenerationSessionIdRef.current === sessionId
);
}, []);
const isDraftNoticeGenerating = useCallback(
(kind: CreationWorkShelfKind, ids: Array<string | null | undefined>) => {
return collectDraftNoticeKeys(kind, ids).some(
(key) => draftGenerationNotices[key]?.status === 'generating',
);
},
[draftGenerationNotices],
);
const isDraftNoticeReadyUnread = useCallback(
(kind: CreationWorkShelfKind, ids: Array<string | null | undefined>) => {
return collectDraftNoticeKeys(kind, ids).some((key) => {
const notice = draftGenerationNotices[key];
return notice?.status === 'ready' && !notice.seen;
});
},
[draftGenerationNotices],
);
const resolveBigFishErrorMessage = useCallback(
(error: unknown, fallback: string) =>
@@ -3584,9 +3612,10 @@ export function PlatformEntryFlowShellImpl({
const { item } = await getMatch3DWorkDetail(profileId);
runtimeProfile = {
...item,
generatedItemAssets:
response.session.draft?.generatedItemAssets ??
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
response.session.draft?.generatedItemAssets,
item.generatedItemAssets,
),
};
setMatch3DProfile(runtimeProfile);
await refreshMatch3DShelf().catch(() => undefined);
@@ -4201,8 +4230,15 @@ export function PlatformEntryFlowShellImpl({
const resetAutoSaveTrackingToIdle =
autosaveCoordinator.resetAutoSaveTrackingToIdle;
const activeMatch3DBackgroundCompileTask =
getMatch3DBackgroundCompileTask(match3dSession?.sessionId);
const activeMatch3DGenerationSessionId =
selectionStage === 'match3d-generating'
? (activeMatch3DGenerationSessionIdRef.current ??
match3dSession?.sessionId ??
null)
: null;
const activeMatch3DBackgroundCompileTask = getMatch3DBackgroundCompileTask(
activeMatch3DGenerationSessionId,
);
const match3dGenerationViewState =
activeMatch3DBackgroundCompileTask?.generationState ??
match3dGenerationState;
@@ -4217,8 +4253,14 @@ export function PlatformEntryFlowShellImpl({
isMiniGameDraftGenerating(
activeMatch3DBackgroundCompileTask?.generationState ?? null,
);
const activePuzzleGenerationSessionId =
selectionStage === 'puzzle-generating'
? (activePuzzleGenerationSessionIdRef.current ??
puzzleSession?.sessionId ??
null)
: null;
const activePuzzleBackgroundCompileTask = getPuzzleBackgroundCompileTask(
puzzleSession?.sessionId,
activePuzzleGenerationSessionId,
);
const puzzleGenerationViewState =
activePuzzleBackgroundCompileTask?.generationState ?? puzzleGenerationState;
@@ -4634,9 +4676,10 @@ export function PlatformEntryFlowShellImpl({
const { item } = await getMatch3DWorkDetail(profileId);
runtimeProfile = {
...item,
generatedItemAssets:
response.session.draft?.generatedItemAssets ??
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
response.session.draft?.generatedItemAssets,
item.generatedItemAssets,
),
};
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DProfile(runtimeProfile);
@@ -6211,11 +6254,23 @@ export function PlatformEntryFlowShellImpl({
if (!hasMatch3DRuntimeAsset(profile.generatedItemAssets)) {
try {
const { item } = await getMatch3DWorkDetail(profile.profileId);
runtimeProfile = item;
runtimeProfile = {
...item,
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
item.generatedItemAssets,
profile.generatedItemAssets,
),
};
} catch {
// 中文注释:详情补读只为拿完整生成素材;失败时继续按摘要开局,避免推荐流卡死。
}
}
runtimeProfile = {
...runtimeProfile,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
runtimeProfile.generatedItemAssets,
),
};
await preloadMatch3DGeneratedRuntimeAssets(
runtimeProfile.generatedItemAssets,
{ expireSeconds: 300 },
@@ -8019,9 +8074,15 @@ export function PlatformEntryFlowShellImpl({
return;
}
const backgroundTask = getPuzzleBackgroundCompileTask(
item.sourceSessionId,
);
const activeGenerationState =
backgroundTask?.generationState ?? puzzleGenerationViewState;
if (
item.sourceSessionId === puzzleSession?.sessionId &&
isMiniGameDraftGenerating(puzzleGenerationViewState)
isMiniGameDraftGenerating(activeGenerationState)
) {
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
@@ -8030,9 +8091,6 @@ export function PlatformEntryFlowShellImpl({
return;
}
const backgroundTask = getPuzzleBackgroundCompileTask(
item.sourceSessionId,
);
if (
backgroundTask &&
isMiniGameDraftGenerating(backgroundTask.generationState)
@@ -8086,31 +8144,78 @@ export function PlatformEntryFlowShellImpl({
item: Match3DWorkSummary,
options: { forceDraft?: boolean } = {},
) => {
const noticeKeys = collectDraftNoticeKeys('match3d', [
item.workId,
item.profileId,
item.sourceSessionId,
]);
const hasUnreadReadyNotice = isDraftNoticeReadyUnread('match3d', [
item.workId,
item.profileId,
item.sourceSessionId,
]);
setMatch3DRun(null);
setMatch3DError(null);
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
markDraftNoticeSeen(
collectDraftNoticeKeys('match3d', [
item.workId,
item.profileId,
item.sourceSessionId,
]),
);
if (item.publicationStatus === 'published' && !options.forceDraft) {
markDraftNoticeSeen(noticeKeys);
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item));
return;
}
if (!item.sourceSessionId?.trim()) {
markDraftNoticeSeen(noticeKeys);
setMatch3DError('这份抓大鹅草稿缺少会话信息,请重新开始创作。');
return;
}
const isMarkedGenerating = isDraftNoticeGenerating('match3d', [
item.workId,
item.profileId,
item.sourceSessionId,
]);
const backgroundTask = getMatch3DBackgroundCompileTask(
item.sourceSessionId,
);
const activeGenerationState =
backgroundTask?.generationState ?? match3dGenerationViewState;
if (hasUnreadReadyNotice) {
try {
const { session: latestSession } =
await match3dCreationClient.getSession(item.sourceSessionId);
setMatch3DSession(latestSession);
setMatch3DFormDraftPayload(null);
const profileId = latestSession.draft?.profileId ?? item.profileId;
const { item: profile } = await getMatch3DWorkDetail(profileId);
match3dFlow.setIsBusy(false);
const started = await startMatch3DRunFromProfile(
profile,
'match3d-result',
);
if (!started) {
setMatch3DProfile(profile);
enterCreateTab();
setSelectionStage('match3d-result');
}
markDraftNoticeSeen(noticeKeys);
return;
} catch (error) {
setMatch3DError(
resolveMatch3DErrorMessage(error, '启动抓大鹅试玩失败。'),
);
markDraftNoticeSeen(noticeKeys);
await refreshMatch3DShelf().catch(() => undefined);
return;
}
}
if (
item.sourceSessionId === match3dSession?.sessionId &&
isMiniGameDraftGenerating(match3dGenerationViewState)
isMiniGameDraftGenerating(activeGenerationState)
) {
enterCreateTab();
selectionStageRef.current = 'match3d-generating';
@@ -8119,9 +8224,6 @@ export function PlatformEntryFlowShellImpl({
return;
}
const backgroundTask = getMatch3DBackgroundCompileTask(
item.sourceSessionId,
);
if (
backgroundTask &&
isMiniGameDraftGenerating(backgroundTask.generationState)
@@ -8139,6 +8241,32 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isMarkedGenerating) {
try {
const { session: latestSession } =
await match3dCreationClient.getSession(item.sourceSessionId);
setMatch3DSession(latestSession);
setMatch3DFormDraftPayload(null);
setMatch3DProfile(null);
setMatch3DGenerationState(
createMiniGameDraftGenerationState('match3d'),
);
enterCreateTab();
selectionStageRef.current = 'match3d-generating';
activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('match3d-generating');
return;
} catch (error) {
setMatch3DError(
resolveMatch3DErrorMessage(error, '读取抓大鹅创作草稿失败。'),
);
await refreshMatch3DShelf().catch(() => undefined);
return;
}
}
markDraftNoticeSeen(noticeKeys);
const restoredSession = await match3dFlow.restoreDraft(
item.sourceSessionId,
);
@@ -8163,6 +8291,8 @@ export function PlatformEntryFlowShellImpl({
[
enterCreateTab,
getMatch3DBackgroundCompileTask,
isDraftNoticeGenerating,
isDraftNoticeReadyUnread,
markDraftNoticeSeen,
match3dFlow,
match3dGenerationViewState,
@@ -8173,6 +8303,7 @@ export function PlatformEntryFlowShellImpl({
setMatch3DFormDraftPayload,
setMatch3DError,
setSelectionStage,
startMatch3DRunFromProfile,
],
);
@@ -10190,7 +10321,7 @@ export function PlatformEntryFlowShellImpl({
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
>
<PuzzleAgentWorkspace
session={puzzleSession}
session={null}
isBusy={isStreamingPuzzleReply}
error={puzzleError}
onBack={leavePuzzleFlow}

View File

@@ -792,6 +792,93 @@ describe('PuzzleResultView', () => {
);
});
test('生成完成回包合并音乐和UI背景后试玩使用最新资源', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const localLevel = {
...base.draft!.levels![0]!,
generationStatus: 'generating' as const,
uiBackgroundPrompt: '旧的UI背景提示词',
uiBackgroundImageSrc: null,
backgroundMusic: null,
};
const incomingLevel = {
...localLevel,
generationStatus: 'ready' as const,
uiBackgroundPrompt: '水果乐园UI背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/fruit-background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/fruit-background.png',
backgroundMusic: {
taskId: 'music-task-fruit',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-fruit',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
prompt: '',
title: '水果乐园',
updatedAt: '2026-05-14T10:00:00.000Z',
},
};
const { rerender } = render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [localLevel],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
rerender(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
coverImageSrc: incomingLevel.coverImageSrc,
coverAssetId: incomingLevel.coverAssetId,
generationStatus: 'ready',
levels: [incomingLevel],
},
updatedAt: '2026-05-14T10:00:00.000Z',
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/fruit-background.png',
);
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/fruit.mp3',
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/fruit-background.png',
backgroundMusic: expect.objectContaining({
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
}),
}),
],
}),
);
});
test('auto saves UI background prompt edits through levels', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({

View File

@@ -302,6 +302,7 @@ function mergeDraftEditStateWithIncomingState(
uiBackgroundImageObjectKey:
incomingLevel.uiBackgroundImageObjectKey ??
level.uiBackgroundImageObjectKey,
backgroundMusic: incomingLevel.backgroundMusic ?? level.backgroundMusic,
generationStatus: incomingLevel.generationStatus || 'ready',
};
});

View File

@@ -18,7 +18,11 @@ import type {
PuzzleAnchorPack,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
@@ -183,6 +187,15 @@ async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
).toBeTruthy();
}
async function expectDraftHubGeneratingBadgeCountAtLeast(count: number) {
const panel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(within(panel).getAllByText('生成中').length).toBeGreaterThanOrEqual(
count,
);
});
}
async function openDiscoverHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '发现');
const panel = getPlatformTabPanel('category');
@@ -450,6 +463,16 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({
),
),
),
normalizeMatch3DGeneratedItemAssetsForRuntime: vi.fn(
(assets: Match3DWorkSummary['generatedItemAssets']) =>
assets ? [...assets] : [],
),
mergeMatch3DGeneratedItemAssetsForRuntime: vi.fn(
(
primaryAssets: Match3DWorkSummary['generatedItemAssets'],
fallbackAssets: Match3DWorkSummary['generatedItemAssets'],
) => (primaryAssets ? [...primaryAssets] : fallbackAssets ? [...fallbackAssets] : []),
),
preloadMatch3DGeneratedRuntimeAssets: vi.fn(() => Promise.resolve()),
}));
@@ -541,22 +564,21 @@ vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
isBusy,
error,
onBack,
onExecuteAction,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onCreateFromForm?: (payload: {
seedText: string;
workTitle: string;
workDescription: string;
pictureDescription: string;
referenceImageSrc: string | null;
}) => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
}) => (
<div className="puzzle-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
<div data-testid="puzzle-workspace-busy-state">
{isBusy ? 'busy' : 'idle'}
</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
@@ -565,13 +587,23 @@ vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
type="button"
disabled={isBusy}
onClick={() => {
onCreateFromForm?.({
const payload = {
seedText: '暖灯猫街',
workTitle: '暖灯猫街',
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
});
};
if (session) {
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: payload.pictureDescription,
...payload,
candidateCount: 1,
});
return;
}
onCreateFromForm?.(payload);
}}
>
稿
@@ -1209,6 +1241,27 @@ function buildPuzzleAnchorPack(): PuzzleAnchorPack {
};
}
function buildMockPuzzleAgentSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
return {
sessionId: 'puzzle-session-1',
seedText: '暖灯猫街',
currentTurn: 0,
progressPercent: 0,
stage: 'collecting_anchors',
anchorPack: buildPuzzleAnchorPack(),
draft: null,
messages: [],
lastAssistantReply: '先说一个你最想做成拼图的画面。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-05-14T10:00:00.000Z',
...overrides,
};
}
function buildClearedPuzzleRun(params: {
runId: string;
entryProfileId: string;
@@ -1700,6 +1753,71 @@ beforeEach(() => {
),
),
);
vi.mocked(
match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime,
).mockImplementation((assets) => {
if (!assets?.length) {
return [];
}
const musicCarrier = assets.find((asset) =>
asset.backgroundMusic?.audioSrc?.trim(),
);
if (!musicCarrier) {
return [...assets];
}
return assets.map((asset, index) =>
index === 0
? {
...asset,
backgroundMusic: asset.backgroundMusic ?? musicCarrier.backgroundMusic,
}
: {
...asset,
backgroundMusic: null,
backgroundMusicTitle: null,
backgroundMusicStyle: null,
backgroundMusicPrompt: null,
}
);
});
vi.mocked(
match3dGeneratedModelCache.mergeMatch3DGeneratedItemAssetsForRuntime,
).mockImplementation((primaryAssets, fallbackAssets) => {
const primary = primaryAssets ?? [];
const fallback = fallbackAssets ?? [];
if (primary.length <= 0) {
return match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime(
fallback,
);
}
if (fallback.length <= 0) {
return match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime(
primary,
);
}
const fallbackById = new Map(fallback.map((asset) => [asset.itemId, asset]));
return match3dGeneratedModelCache.normalizeMatch3DGeneratedItemAssetsForRuntime(
primary.map((asset) => {
const fallbackAsset = fallbackById.get(asset.itemId);
return fallbackAsset
? {
...asset,
imageSrc: asset.imageSrc ?? fallbackAsset.imageSrc ?? null,
imageObjectKey:
asset.imageObjectKey ?? fallbackAsset.imageObjectKey ?? null,
imageViews:
asset.imageViews && asset.imageViews.length > 0
? asset.imageViews
: (fallbackAsset.imageViews ?? []),
backgroundMusic:
asset.backgroundMusic ?? fallbackAsset.backgroundMusic ?? null,
backgroundAsset:
asset.backgroundAsset ?? fallbackAsset.backgroundAsset ?? null,
}
: asset;
}),
);
});
vi.mocked(
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
).mockResolvedValue(undefined);
@@ -2749,7 +2867,7 @@ test('running match3d form generation can return to draft tab and reopen progres
await openDraftHub(user);
expect(await screen.findByText('抓大鹅草稿')).toBeTruthy();
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
screen.getByRole('button', { name: /稿/u }),
@@ -2761,6 +2879,93 @@ test('running match3d form generation can return to draft tab and reopen progres
});
});
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-persisted-session',
draft: null,
stage: 'collecting_config',
});
const persistedRunningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-persisted-session',
stage: 'draft_ready',
draft: {
profileId: 'match3d-running-persisted-profile',
gameName: '赛博水果摊',
themeText: '赛博水果摊',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
});
const persistedRunningWork: Match3DWorkSummary = {
workId: 'match3d-running-persisted-work',
profileId: 'match3d-running-persisted-profile',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-running-persisted-session',
gameName: '赛博水果摊',
themeText: '赛博水果摊',
summary: '正在生成玩法素材。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-14T10:30:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
};
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
vi.mocked(match3dCreationClient.executeAction).mockRejectedValueOnce(
new Error('素材生成仍在后台处理'),
);
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: persistedRunningSession,
});
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [persistedRunningWork],
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: persistedRunningWork,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findAllByText('素材生成仍在后台处理'),
).not.toHaveLength(0);
vi.mocked(match3dCreationClient.getSession).mockClear();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
'match3d-running-persisted-session',
);
});
test('running match3d form generation keeps other creation templates available', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
@@ -2966,8 +3171,8 @@ test('running match3d form generation keeps same template generation available',
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('抓大鹅草稿').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('生成中').length).toBeGreaterThanOrEqual(2);
});
await expectDraftHubGeneratingBadgeCountAtLeast(2);
await act(async () => {
resolveFirstCompile({
@@ -2983,6 +3188,126 @@ test('running match3d form generation keeps same template generation available',
});
});
test('running puzzle form generation creates a new puzzle draft on same template submit', async () => {
const user = userEvent.setup();
const firstSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-1',
});
const secondSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-2',
});
let resolveFirstCompile!: (value: {
operation: {
operationId: string;
type: 'compile_puzzle_draft';
status: 'completed';
phaseLabel: string;
phaseDetail: string;
progress: number;
};
session: PuzzleAgentSessionSnapshot;
}) => void;
let resolveSecondCompile!: (value: {
operation: {
operationId: string;
type: 'compile_puzzle_draft';
status: 'completed';
phaseLabel: string;
phaseDetail: string;
progress: number;
};
session: PuzzleAgentSessionSnapshot;
}) => void;
vi.mocked(createPuzzleAgentSession)
.mockResolvedValueOnce({ session: firstSession })
.mockResolvedValueOnce({ session: secondSession });
vi.mocked(executePuzzleAgentAction)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstCompile = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondCompile = resolve;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(screen.getByTestId('puzzle-workspace-busy-state')).toHaveProperty(
'textContent',
'idle',
);
const secondGenerateButton = await screen.findByRole('button', {
name: '生成草稿',
});
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(secondGenerateButton);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2);
});
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
1,
'puzzle-parallel-session-1',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
2,
'puzzle-parallel-session-2',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('拼图草稿').length).toBeGreaterThanOrEqual(2);
});
await expectDraftHubGeneratingBadgeCountAtLeast(2);
await act(async () => {
resolveFirstCompile({
operation: {
operationId: 'compile-puzzle-parallel-1',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-1',
}),
});
resolveSecondCompile({
operation: {
operationId: 'compile-puzzle-parallel-2',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-parallel-session-2',
}),
});
});
});
test('match3d result trial passes generated models into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
@@ -3318,6 +3643,134 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
expect(screen.getByText('自动试玩抓大鹅')).toBeTruthy();
});
test('completed match3d draft notice first opens trial then reopens result', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-notice-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-notice-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-notice-item-1-item/image.png',
imageViews: [],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: 'task-notice-strawberry',
subscriptionKey: 'sub-notice-strawberry',
status: 'image_ready',
error: null,
},
];
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-notice-session-1',
draft: null,
stage: 'collecting_config',
});
const generatedSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-notice-session-1',
stage: 'draft_ready',
draft: {
profileId: 'match3d-notice-profile-1',
gameName: '红点自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
});
const generatedProfile: Match3DWorkSummary = {
workId: 'match3d-notice-work-1',
profileId: 'match3d-notice-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-notice-session-1',
gameName: '红点自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
let resolveCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: generatedSession,
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: generatedProfile,
});
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [generatedProfile],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(generatedProfile.profileId),
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await act(async () => {
resolveCompile({ session: generatedSession });
});
expect(await screen.findByLabelText('新生成完成')).toBeTruthy();
await user.click(
await screen.findByRole('button', {
name: //u,
}),
);
expect(await screen.findByText(//u)).toBeTruthy();
expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull();
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(screen.queryByLabelText('新生成完成')).toBeNull();
await user.click(
await screen.findByRole('button', {
name: //u,
}),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
});
test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => {
const user = userEvent.setup();
const generatedDraft: PuzzleResultDraft = {
@@ -3364,6 +3817,22 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/auto-candidate.png',
coverAssetId: 'asset-1',
uiBackgroundPrompt: '水果乐园竖屏纯背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: {
taskId: 'music-task-auto-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-auto-1',
assetKind: 'puzzle_background_music',
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
prompt: '',
title: '水果乐园',
updatedAt: '2026-05-14T10:00:00.000Z',
},
generationStatus: 'ready',
},
],
@@ -3412,6 +3881,16 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
expect.objectContaining({
levelName: '雨夜猫街',
coverImageSrc: '/puzzle/auto-candidate.png',
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
backgroundMusic: expect.objectContaining({
audioSrc:
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
}),
}),
],
}),
);
expect(screen.queryByText('拼图结果页')).toBeNull();
@@ -4958,9 +5437,10 @@ test('puzzle draft result back button returns to creation hub', async () => {
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(
screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeTruthy();
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeNull();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
@@ -5872,7 +6352,7 @@ test('running custom world draft generation can return to creation center with s
await openDraftHub(user);
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
});
test('refresh restores running draft generation progress instead of agent workspace', async () => {

View File

@@ -7,6 +7,8 @@ import {
getMatch3DGeneratedImageAssetSources,
getMatch3DGeneratedModelAssetSources,
hasMatch3DGeneratedImageAsset,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
preloadMatch3DGeneratedImageAssets,
preloadMatch3DGeneratedModelAssets,
readMatch3DGeneratedModelBytes,
@@ -263,4 +265,86 @@ describe('match3dGeneratedModelCache', () => {
'views%2Fview-01.png',
);
});
test('作品级背景音乐会归一化到首个抓大鹅素材', () => {
const assets = normalizeMatch3DGeneratedItemAssetsForRuntime([
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/match3d/strawberry.png',
imageObjectKey: null,
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: '/match3d/apple.png',
imageObjectKey: null,
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
backgroundMusicTitle: '果园轻舞',
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/music.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
]);
expect(assets[0]?.backgroundMusic?.audioSrc).toBe(
'/generated-match3d-assets/audio/music.mp3',
);
expect(assets[0]?.backgroundMusicTitle).toBe('果园轻舞');
expect(assets[1]?.backgroundMusic).toBeNull();
});
test('合并 action 草稿和作品详情时保留详情里的背景音乐', () => {
const assets = mergeMatch3DGeneratedItemAssetsForRuntime(
[
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/match3d/strawberry.png',
imageObjectKey: null,
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
},
],
[
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/match3d/strawberry.png',
imageObjectKey: null,
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/music.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
],
);
expect(assets).toHaveLength(1);
expect(assets[0]?.backgroundMusic?.audioSrc).toBe(
'/generated-match3d-assets/audio/music.mp3',
);
});
});

View File

@@ -125,6 +125,160 @@ export function hasMatch3DGeneratedImageAsset(
);
}
function findMatch3DBackgroundMusicCarrier(
assets: readonly Match3DGeneratedItemAsset[],
) {
return assets.find((asset) => asset.backgroundMusic?.audioSrc?.trim());
}
function findMatch3DBackgroundMusicMetadataCarrier(
assets: readonly Match3DGeneratedItemAsset[],
) {
return assets.find(
(asset) =>
asset.backgroundMusicTitle?.trim() ||
asset.backgroundMusicStyle?.trim() ||
asset.backgroundMusicPrompt?.trim(),
);
}
/**
* 抓大鹅背景音乐当前暂存在 generatedItemAssets 里,但它表达的是作品级音乐。
* 归一化到首个素材,避免前端只读首项时把已生成音乐显示成“暂无音乐”。
*/
export function normalizeMatch3DGeneratedItemAssetsForRuntime(
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
) {
if (!assets?.length) {
return [];
}
const musicCarrier = findMatch3DBackgroundMusicCarrier(assets);
const metadataCarrier =
musicCarrier ?? findMatch3DBackgroundMusicMetadataCarrier(assets);
if (!musicCarrier && !metadataCarrier) {
return [...assets];
}
return assets.map((asset, index) => {
if (index !== 0) {
if (
!asset.backgroundMusic &&
!asset.backgroundMusicTitle &&
!asset.backgroundMusicStyle &&
!asset.backgroundMusicPrompt
) {
return asset;
}
return {
...asset,
backgroundMusic: null,
backgroundMusicTitle: null,
backgroundMusicStyle: null,
backgroundMusicPrompt: null,
};
}
return {
...asset,
backgroundMusic:
asset.backgroundMusic ?? musicCarrier?.backgroundMusic ?? null,
backgroundMusicTitle:
asset.backgroundMusicTitle ??
metadataCarrier?.backgroundMusicTitle ??
musicCarrier?.backgroundMusic?.title ??
null,
backgroundMusicStyle:
asset.backgroundMusicStyle ??
metadataCarrier?.backgroundMusicStyle ??
null,
backgroundMusicPrompt:
asset.backgroundMusicPrompt ??
metadataCarrier?.backgroundMusicPrompt ??
musicCarrier?.backgroundMusic?.prompt ??
null,
};
});
}
export function mergeMatch3DGeneratedItemAssetsForRuntime(
primaryAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
fallbackAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
) {
const primary = primaryAssets ?? [];
const fallback = fallbackAssets ?? [];
if (primary.length <= 0) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(fallback);
}
if (fallback.length <= 0) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(primary);
}
const fallbackById = new Map(fallback.map((asset) => [asset.itemId, asset]));
const merged = primary.map((asset) => {
const fallbackAsset = fallbackById.get(asset.itemId);
if (!fallbackAsset) {
return asset;
}
const hasPrimaryImage = getMatch3DGeneratedImageViewSources(asset).length > 0;
const hasPrimaryModel = resolveMatch3DGeneratedModelAssetSource(asset).length > 0;
return {
...asset,
itemName: asset.itemName.trim() || fallbackAsset.itemName,
imageSrc: asset.imageSrc?.trim()
? asset.imageSrc
: (fallbackAsset.imageSrc ?? null),
imageObjectKey: asset.imageObjectKey?.trim()
? asset.imageObjectKey
: (fallbackAsset.imageObjectKey ?? null),
imageViews:
asset.imageViews && asset.imageViews.length > 0
? asset.imageViews
: (fallbackAsset.imageViews ?? []),
modelSrc: asset.modelSrc?.trim()
? asset.modelSrc
: (fallbackAsset.modelSrc ?? null),
modelObjectKey: asset.modelObjectKey?.trim()
? asset.modelObjectKey
: (fallbackAsset.modelObjectKey ?? null),
modelFileName: asset.modelFileName?.trim()
? asset.modelFileName
: (fallbackAsset.modelFileName ?? null),
taskUuid: asset.taskUuid?.trim()
? asset.taskUuid
: (fallbackAsset.taskUuid ?? null),
subscriptionKey: asset.subscriptionKey?.trim()
? asset.subscriptionKey
: (fallbackAsset.subscriptionKey ?? null),
backgroundMusic:
asset.backgroundMusic ?? fallbackAsset.backgroundMusic ?? null,
backgroundMusicTitle:
asset.backgroundMusicTitle ?? fallbackAsset.backgroundMusicTitle ?? null,
backgroundMusicStyle:
asset.backgroundMusicStyle ?? fallbackAsset.backgroundMusicStyle ?? null,
backgroundMusicPrompt:
asset.backgroundMusicPrompt ??
fallbackAsset.backgroundMusicPrompt ??
null,
backgroundAsset: asset.backgroundAsset ?? fallbackAsset.backgroundAsset ?? null,
clickSound: asset.clickSound ?? fallbackAsset.clickSound ?? null,
soundPrompt: asset.soundPrompt ?? fallbackAsset.soundPrompt ?? null,
status:
!hasPrimaryImage && !hasPrimaryModel && fallbackAsset.status
? fallbackAsset.status
: asset.status,
error: asset.error ?? fallbackAsset.error ?? null,
};
});
for (const fallbackAsset of fallback) {
if (!merged.some((asset) => asset.itemId === fallbackAsset.itemId)) {
merged.push(fallbackAsset);
}
}
return normalizeMatch3DGeneratedItemAssetsForRuntime(merged);
}
export function getMatch3DGeneratedModelAssetSources(
assets: readonly Match3DGeneratedItemAsset[] = [],
) {
@@ -225,7 +379,10 @@ export async function preloadMatch3DGeneratedRuntimeAssets(
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
) {
// 中文注释:新抓大鹅运行态以 2D 图片为主3D 模型只作为历史草稿预览兼容。
await preloadMatch3DGeneratedImageAssets(assets, options);
await preloadMatch3DGeneratedImageAssets(
normalizeMatch3DGeneratedItemAssetsForRuntime(assets),
options,
);
}
export function clearMatch3DGeneratedModelBytesCache() {