From 481a27fc53690172b80e480f512ebd3f626060e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Mon, 11 May 2026 20:27:41 +0800 Subject: [PATCH] 1 --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 14 +- ...EATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md | 4 +- ..._DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md | 10 + ...VE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md | 14 +- ...CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md | 2 +- ..._RODIN_GEN2_MODEL_GENERATION_2026-05-08.md | 9 +- ...FT_ASSET_GENERATION_PIPELINE_2026-05-10.md | 47 +- .../NEW_WORK_ENTRY_CONFIG_2026-05-01.md | 4 +- ...ZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md | 89 + .../shared/src/contracts/creationAudio.ts | 53 + packages/shared/src/contracts/index.ts | 1 + packages/shared/src/contracts/match3dWorks.ts | 8 + .../shared/src/contracts/puzzleAgentDraft.ts | 2 + packages/shared/src/index.ts | 1 + server-rs/crates/api-server/src/app.rs | 81 +- .../api-server/src/creation_entry_config.rs | 11 +- server-rs/crates/api-server/src/http_error.rs | 8 + .../api-server/src/hyper3d_generation.rs | 16 +- server-rs/crates/api-server/src/match3d.rs | 982 +++++++++- server-rs/crates/api-server/src/puzzle.rs | 134 +- .../src/vector_engine_audio_generation.rs | 376 +++- .../crates/module-puzzle/src/application.rs | 7 + .../module-puzzle/src/creative_tools.rs | 1 + server-rs/crates/module-puzzle/src/domain.rs | 20 + .../crates/shared-contracts/src/assets.rs | 2 +- .../shared-contracts/src/creation_audio.rs | 128 ++ server-rs/crates/shared-contracts/src/lib.rs | 1 + .../shared-contracts/src/match3d_agent.rs | 6 + .../shared-contracts/src/match3d_works.rs | 12 + .../shared-contracts/src/puzzle_agent.rs | 4 + server-rs/crates/spacetime-client/src/lib.rs | 5 +- .../crates/spacetime-client/src/mapper.rs | 29 + .../spacetime-module/src/match3d/mod.rs | 227 ++- .../src/runtime/creation_entry_config.rs | 131 +- src/components/CustomWorldGenerationView.tsx | 8 +- .../CustomWorldCreationHub.test.tsx | 51 +- .../CustomWorldCreationHub.tsx | 14 +- .../custom-world-home/CustomWorldWorkCard.tsx | 11 + .../custom-world-home/creationWorkShelf.ts | 37 +- .../match3d-result/Match3DResultView.test.tsx | 203 ++ .../match3d-result/Match3DResultView.tsx | 537 +++++- .../match3d-runtime/Match3DPhysicsBoard.tsx | 4 +- .../Match3DRuntimeShell.test.tsx | 61 + .../match3d-runtime/Match3DRuntimeShell.tsx | 68 + .../PlatformEntryFlowShellImpl.tsx | 1656 ++++++++++++++--- ...atformCreationAgentFlowController.test.tsx | 268 ++- .../usePlatformCreationAgentFlowController.ts | 112 +- .../puzzle-result/PuzzleResultView.tsx | 213 ++- ...gEntryFlowShell.agent.interaction.test.tsx | 403 ++-- .../RpgEntryHomeView.recharge.test.tsx | 133 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 666 +++---- .../useRpgCreationSessionController.ts | 50 +- src/index.css | 327 +++- .../creationAudioGenerationClient.ts | 90 + src/services/creation-audio/index.ts | 1 + src/services/match3d-works/index.ts | 2 + .../match3d-works/match3dWorksClient.ts | 24 + .../miniGameDraftGenerationProgress.test.ts | 18 + .../miniGameDraftGenerationProgress.ts | 53 +- 60 files changed, 6357 insertions(+), 1100 deletions(-) create mode 100644 docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md create mode 100644 packages/shared/src/contracts/creationAudio.ts create mode 100644 server-rs/crates/shared-contracts/src/creation_audio.rs create mode 100644 src/services/creation-audio/creationAudioGenerationClient.ts create mode 100644 src/services/creation-audio/index.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 8443338b..3df7c8f4 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路 + +- 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。 +- 决策:新增 `/api/creation/audio/*` 通用创作音频路由,后端统一负责 VectorEngine 音频任务、OSS 转存、`asset_object` 与 `asset_entity_binding` 写入;视觉小说旧路由保留并复用同一持久化逻辑。拼图背景音乐暂存到首关 `levels_json[0].backgroundMusic/background_music`;抓大鹅背景音乐暂存到 `generated_item_assets_json[0].backgroundMusic/background_music`,单物体点击音效存到对应 item 的 `clickSound/click_sound`。本轮不新增 SpacetimeDB 表和字段。 +- 影响范围:拼图结果页、抓大鹅结果页、抓大鹅运行态音频播放、通用创作音频 shared contracts、`api-server` 音频路由和资产绑定。 +- 验证方式:执行拼图/抓大鹅结果页定向测试、`npm run typecheck`、`cargo test -p api-server vector_engine_audio_generation`、`cargo test -p shared-contracts creation_audio`、`cargo check -p api-server`,真实生成需配置 VectorEngine 与 OSS 私密环境。 +- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`。 + ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 - 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 image-2 真实背景图的固定接入位。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 4f55c772..ce52b92d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -439,16 +439,16 @@ ## 抓大鹅草稿生成恢复 Rodin 后要并行生成模型、同步长超时和 GLB 私有读取 -- 现象:抓大鹅草稿生成重新接回 Rodin 后,前端可能在模型轮询和 GLB 转存完成前超时;或结果页把 `/generated-match3d-assets/.../model.glb` 直接当浏览器 URL 加载导致私有 OSS/CORS 读取失败。 +- 现象:抓大鹅草稿生成重新接回 Rodin 后,前端可能在模型轮询和 GLB 转存完成前超时;或 Hyper3D 控制台显示 3 个任务已完成,但草稿进度页仍停留在 `生成3D模型`;或结果页把 `/generated-match3d-assets/.../model.glb` 直接当浏览器 URL 加载导致私有 OSS/CORS 读取失败。 - 原因:`match3d_compile_draft` 会完成作品元信息、素材图、切图上传、3 件 Rodin 图生模型、GLB 下载和 OSS 转存,耗时远长于普通 Agent action;如果 3 件 Rodin 模型逐个提交和轮询,等待时间会线性叠加;同时 generated 私有资产不能被 Three.js 直接 `fetch`。 -- 处理:切图和图片入库后,所有 Rodin 图生模型任务必须并行提交、并行轮询、并行下载转存;Match3D creation client 的 `executeAction` 必须给长超时,当前为 20 分钟;生成进度页要包含 `生成3D模型` 阶段;结果页模型预览、场内运行态和备选栏预览都必须通过 `/api/assets/read-bytes` 读取 GLB 字节后交给 Three.js GLTFLoader,不要直接请求裸 generated 路径。 +- 处理:切图和图片入库后,所有 Rodin 图生模型任务必须并行提交、并行轮询、并行下载转存;Match3D creation client 的 `executeAction` 必须给长超时,当前为 20 分钟;生成进度页要包含 `生成3D模型` 阶段,但它不是 Hyper3D task 订阅页,而是在长 action 执行期间旁路轮询 session / work detail,并用 profile 的 `generatedItemAssets` 更新完成数量;控制台看到 Rodin `Done` 后仍需等待下载列表、GLB 下载、OSS 转存和草稿 JSON 写回。结果页模型预览、场内运行态和备选栏预览都必须通过 `/api/assets/read-bytes` 读取 GLB 字节后交给 Three.js GLTFLoader,不要直接请求裸 generated 路径。排查时按同一个 session/profile 查看 api-server 日志:`抓大鹅 Rodin 状态轮询返回`、`抓大鹅 Rodin 下载列表轮询返回`、`抓大鹅 Rodin GLB 下载完成`、`抓大鹅 Rodin GLB 转存 OSS 完成`;同时检查前端 work detail 响应里的 `generatedItemAssets[].status/modelObjectKey/error`。 - 验证:`npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`、`npm run typecheck`;真实联调需配置 `VECTOR_ENGINE_API_KEY`、`HYPER3D_API_KEY` 和 OSS 变量。 - 关联:`src/services/match3d-creation/match3dCreationClient.ts`、`src/services/creation-agent/creationAgentClientFactory.ts`、`src/components/match3d-result/Match3DModelPreview.tsx`、`src/components/match3d-runtime/Match3DPhysicsBoard.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## Rodin 完成态后下载列表可能延迟或字段漂移 - 现象:抓大鹅草稿生成时 Rodin 状态已完成,但 `/api/creation/match3d/sessions/{sessionId}/actions` 返回 502,提示 `{物品名} 3D 模型已完成但未返回可下载模型文件:{taskUuid}`。 -- 原因:Hyper3D Rodin 官方示例要求看 `jobs` 列表,只有所有 job 都 `Done` 才能进入下载;旧聚合若只看 root status 或第一个 job,可能在 preview job 完成但模型 job 仍在生成时提前下载。另外任务完成和下载列表文件发布不是强同步的;上游下载结果还可能使用 `fileUrl`、`signedUrl`、`presignedUrl`、`fileName` 等字段别名,旧解析器只识别 `url/downloadUrl/name/file_name/filename` 时会得到空列表。 +- 原因:Hyper3D Rodin 官方 `check-status_reset_v` 示例要求看 `jobs` 列表,只有所有 job 都 `Done` 才能进入下载;`download-results_reset_v` 还明确要求用生成响应顶层 `uuid` 作为 `task_uuid`,不要用 `jobs.uuids` 子任务 uuid。旧聚合若只看 root status 或第一个 job,可能在 preview job 完成但模型 job 仍在生成时提前下载。另外任务完成和下载列表文件发布不是强同步的;上游下载结果还可能使用 `fileUrl`、`signedUrl`、`presignedUrl`、`fileName` 等字段别名,旧解析器只识别 `url/downloadUrl/name/file_name/filename` 时会得到空列表。 - 处理:`query_task_status` 聚合状态必须以 `jobs` 为准:任一 failed 即 failed,全部 done 才 done;`match3d_compile_draft` 在状态完成后对 `query_downloads` 继续轮询;下载解析兼容常见 URL 和文件名字段别名;模型选择优先 `.glb`,可兜底到非图片下载文件,但只有 preview/png/jpg/webp 这类预览图时必须继续失败,不能伪装成 GLB。 - 验证:`cargo test -p api-server match3d_model_download --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server extracts_download_files --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server hyper3d --manifest-path server-rs/Cargo.toml`。 - 关联:`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 @@ -469,6 +469,14 @@ - 验证:`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`。 - 关联:`server-rs/crates/spacetime-module/src/match3d/*`、`server-rs/crates/spacetime-client/src/mapper.rs`、`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 +## 抓大鹅试玩和正式运行态不要只读草稿页本地模型预览 + +- 现象:历史草稿页 `3D素材` Tab 能看到水果模型,但点击试玩或从推荐 / 公开作品进入正式抓大鹅时,局内仍显示默认积木素材。 +- 原因:结果页手动 `重新生成` 曾只更新本地 `assetDrafts.downloads`,没有把新的 GLB 写回 `generatedItemAssets`;历史数据还可能只有 `modelObjectKey` 而没有 `modelSrc`;推荐流内嵌运行态若只读卡片摘要,卡片缺素材时会把已持久化 profile 模型丢掉;本次生成 response 的 draft 也可能比 profile 旧,只带图片而不带模型。 +- 处理:结果页模型预览和运行态都按 `modelSrc || modelObjectKey` 读取;手动重新生成成功后把素材草稿重新序列化并写回作品 profile;`Match3DResultView` 合并同 `itemId` 的 draft/profile 素材,用 profile 已有模型补齐旧 draft;推荐流内嵌运行态启动前若卡片摘要没有生成素材,补读 `getMatch3DWorkDetail(profileId)` 并把详情资产传给 `Match3DRuntimeShell`。 +- 验证:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`,并检查历史草稿和公开 M3 作品的 Network 响应里 `generatedItemAssets[].modelSrc/modelObjectKey`。 +- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/match3d-runtime/Match3DPhysicsBoard.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + ## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉 - 现象:AI 或兜底生成的 `3D素材` 标签在后端规范化后变成 `D素材`。 diff --git a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md index c0c29fcb..b2330aa5 100644 --- a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md +++ b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md @@ -18,7 +18,7 @@ ```text 标题:10分钟创作一个精品互动玩法 -模板 Tab:拼图 / 抓大鹅 / 视觉小说 / AIRP +模板 Tab:拼图 / 抓大鹅 / 视觉小说(敬请期待)/ AIRP 默认内容:拼图创作表单 ``` @@ -34,7 +34,7 @@ 1. 打开“创作”一级 Tab 时默认停留在拼图 Tab,不主动创建拼图 session。 2. 点击拼图表单“生成草稿”后,才创建拼图 session 并执行 `compile_puzzle_draft`。 3. 拼图表单内的模板按钮使用 `tablist / tab` 语义,点击后只填充画面描述。 -4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;未开放模板保持禁用。 +4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;视觉小说与 AIRP 当前保持敬请期待禁用态。 5. `creative-agent` 不出现在模板 Tab 和选择弹层中,不再作为创作 Tab 首屏入口。 6. 方洞挑战暂时从创作页完全隐藏,不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中;既有作品链路继续保留。 diff --git a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md index a17f8e3d..d0e1f45a 100644 --- a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md +++ b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md @@ -114,3 +114,13 @@ - 未登录用户点击推荐页封面时,再次打开同一个登录弹窗;登录成功后由既有受保护动作继续进入作品详情或玩法入口。 - 未登录状态下点击“下一个”只切换下一张推荐封面,不触发登录弹窗,也不启动玩法。 - 已登录用户继续沿用推荐页内嵌运行态、上下滑切换和底部“下一个”行为。 + +## 10. 2026-05-11 草稿生成中与新完成标记 + +草稿生成过程页允许用户直接返回创作中心并自由使用平台其它功能: + +- 点击生成过程页的返回按钮时,当前生成任务继续在后台执行,页面回到创作中心,不清空生成状态。 +- 用户再进入草稿 Tab 并点击同一草稿时,若生成仍未完成,进入对应生成过程页查看最新进度;若已完成,直接进入对应结果页。 +- 草稿作品卡在生成中展示“生成中”状态标记;新生成完成且用户尚未查看的草稿在卡片右上角展示红点。 +- 底部一级“草稿”Tab 在存在未查看新完成草稿时展示红点;用户点击查看带红点的作品后,该作品红点消失。若草稿页已无任何带红点作品,底部“草稿”Tab 红点同步消失。 +- 生成完成时如果用户仍停留在对应生成过程页,可自动进入结果页;如果用户已经回到创作中心或其它功能页,不打断当前操作。 diff --git a/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md b/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md index 842ba96f..ea644c98 100644 --- a/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md +++ b/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md @@ -69,7 +69,7 @@ ### 3.1 必须完成的模板闭环 -1. 平台创作中心展示 `视觉小说` 入口,并在实现完成后从 `open: false` 改为 `open: true`。 +1. 平台创作中心展示 `视觉小说` 入口;2026-05-11 起当前运营状态回退为 `open: false`,入口显示敬请期待,不允许创建新视觉小说草稿。 2. 创作者可选择 `idea`、`document`、`blank` 三种起点创建视觉小说底稿。 3. Agent 或表单工作台生成 / 编辑同一份 `VisualNovelResultDraft`。 4. 结果页可编辑世界观、角色、场景、剧情阶段、资产和运行时配置。 @@ -692,27 +692,27 @@ GET /api/runtime/profile/save-archives ### 11.1 入口配置 -当前 `src/config/newWorkEntryConfig.ts` 已存在: +入口配置事实源已经迁移到 SpacetimeDB 的 `creation_entry_type_config` 表,默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`。2026-05-11 起视觉小说当前默认状态为: ```ts { id: 'visual-novel', title: '视觉小说', - subtitle: '敬请期待', + subtitle: '分支叙事体验', badge: '敬请期待', visible: true, open: false, } ``` -实现完成后更新为: +重新开放创建时再通过后台入口开关或默认种子更新为: ```ts { id: 'visual-novel', title: '视觉小说', - subtitle: 'AI 生成可玩的视觉小说', - badge: '新玩法', + subtitle: '分支叙事体验', + badge: '可创建', visible: true, open: true, } @@ -1754,7 +1754,7 @@ VN-11 从 Batch 1 开始持续运行,最终阻塞发布。 ### 17.1 正向验收 -1. `visual-novel` 入口可见并可点击创建。 +1. `visual-novel` 入口当前可见但处于敬请期待禁用态;重新开放 `open=true` 后,再验收点击创建闭环。 2. 一句话创建能生成可编辑底稿。 3. 文档创建能读取平台文档资产并生成底稿。 4. 空白创建能进入结果页。 diff --git a/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md b/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md index c96168ed..44929935 100644 --- a/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md +++ b/docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md @@ -65,7 +65,7 @@ Admin Web -> spacetime-module creation_entry_type_config 表 ``` -`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态,并让 api-server 熔断对应玩法 API。隐藏入口但仍保留既有作品号、广场详情或试玩链路时,应只关闭 `visible`,不要关闭 `open`。 +`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态,并让 api-server 熔断对应玩法创作 / 运行态 API。隐藏入口但仍保留既有作品号、广场详情或试玩链路时,应只关闭 `visible`,不要关闭 `open`。 ## 注意 diff --git a/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md b/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md index 68e6c299..5900b0f3 100644 --- a/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md +++ b/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md @@ -13,6 +13,8 @@ - `https://developer.hyper3d.ai/api-specification/rodin-generation-gen2` - `https://developer.hyper3d.ai/api-specification/check-status` - `https://developer.hyper3d.ai/api-specification/download-results` +- `https://developer.hyper3d.ai/api-specification/check-status_reset_v` +- `https://developer.hyper3d.ai/api-specification/download-results_reset_v` 上游接口: @@ -24,6 +26,11 @@ POST https://api.hyper3d.com/api/v2/download Rodin Gen-2 提交接口必须使用 `multipart/form-data`。文本生成时提交 `prompt`;图片生成时提交一个或多个 `images` 文件,可选 `prompt` 作为辅助描述。两种模式均固定提交 `tier=Gen-2`。 +官方 `*_reset_v` 文档对状态和下载有两个关键约束: + +1. 生成接口返回的顶层 `uuid` 是后续下载接口的 `task_uuid`,不要使用 `jobs.uuids` 中的子任务 uuid 作为下载参数。 +2. 状态接口使用 `subscription_key` 查询,并返回 `jobs[]`;只有所有 job 的 `status` 都为 `Done` 才能进入下载,任一 job `Failed` 都应视为任务失败。 + ## 3. 环境变量 ```text @@ -111,7 +118,7 @@ RODIN_MODEL_REQUEST_TIMEOUT_MS } ``` -状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`。下载接口只返回上游 `list.name` 与 `list.url`,不在后端转存文件。 +状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`,整体状态必须以 `jobs[]` 聚合结果为准。下载接口只返回上游 `list.name` 与 `list.url`,不在 Hyper3D 代理路由中转存文件;具体玩法若需要持久化模型,应在玩法编排层等待 `Done` 后再下载并转存。 ## 7. 验收 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 358b0cd8..1d83bf4c 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 @@ -11,10 +11,11 @@ 入口仍复用 `Match3DAgentWorkspace` 表单。点击 `生成抓大鹅草稿` 后: 1. 创建 Match3D session。 -2. 进入 `match3d-generating` 生成过程页。 -3. 过程页复用拼图生成页的 `CustomWorldGenerationView` 结构。 -4. 生成成功后自动进入 `match3d-result`。 -5. 生成失败时停留在生成过程页,允许重新生成或返回创作中心。 +2. 后端先用当前题材和本地兜底元信息创建同一个 Match3D 草稿 profile,草稿 Tab 必须立即能看到这份存档。 +3. 进入 `match3d-generating` 生成过程页。 +4. 过程页复用拼图生成页的 `CustomWorldGenerationView` 结构。 +5. 生成成功后自动进入 `match3d-result`。 +6. 生成失败时停留在生成过程页,允许重新生成或返回创作中心;重新生成必须复用同一个 session / profile,并从缺失的素材阶段继续,不新建第二份草稿。 生成页步骤固定为: @@ -24,6 +25,8 @@ 生成页只展示题材和物品数量,不展示玩法规则说明。 +当前 `match3d-generating` 进度页不是后端 task 状态订阅页,而是一个覆盖 `match3d_compile_draft` 长 action 的本地时间进度页:前端每 500ms 以本地时间刷新阶段展示,真正的生成完成仍以 action 返回为准。为避免长 action 未返回时页面完全无感,生成页在 `match3d_compile_draft` 执行期间每 3 秒旁路读取一次 session 和 work detail,并用 profile 中已写回的 `generatedItemAssets` 更新 `生成3D模型` 的完成数量。Hyper3D 控制台中看到 3 个 Rodin 任务已经 `Done` 后,页面仍可能继续停留在 `生成3D模型`,此时通常表示后端还在等待下载列表、下载 GLB、转存 OSS 或写回 `generated_item_assets_json`;若 `generatedItemAssets` 已出现 `model_ready`,前端应逐步显示完成数量。排查时应看 api-server 日志中的 `抓大鹅 Rodin 状态轮询返回`、`抓大鹅 Rodin 下载列表轮询返回`、`抓大鹅 Rodin GLB 下载完成` 和 `抓大鹅 Rodin GLB 转存 OSS 完成`。 + ## 3. 后端编排边界 外部模型和 OSS 上传全部由 `api-server` 编排,不进入 SpacetimeDB reducer。SpacetimeDB 继续只负责 Match3D 会话、草稿和作品 profile 的确定性写入。 @@ -32,18 +35,19 @@ 1. 读取 session config。 2. 将本次 MVP 的 `clearCount` 固定为 `3`,并同步用于草稿编译。 -3. 基于入口页题材设定文本调用文本模型生成作品元信息。模型固定请求 `gpt-4o`,只返回 JSON,其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写。 -4. 调用文本模型生成 `3` 个题材下的短物品名称。 -5. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine,不重新接入 APIMart 图片网关。 -6. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3` 格。 -7. 将素材图和每张独立图片上传到 OSS,其中独立图片作为草稿页素材预览和 Rodin 图生模型参考图。 -8. 使用每张独立图片作为参考图,并行调用 Hyper3D Rodin 图生模型;所有 3D 模型任务必须在同一阶段同时提交、同时轮询状态、同时下载并转存 OSS,禁止逐个物品串行等待模型完成。每个任务按官方 minimal example 轮询状态;只有 `jobs` 全部进入 `Done` 才能视为任务完成,任一 job `Failed` 则失败。完成后选择 `.glb` 下载文件,并把 GLB 转存到 OSS。Rodin 的 `subscriptionKey` 是上游 opaque token,不做 256 字符这类短文本长度限制。Rodin 任务状态进入完成态后,下载列表仍可能延迟发布;后端必须对下载列表继续轮询,并兼容 `url`、`downloadUrl`、`fileUrl`、`signedUrl` 等下载字段别名,只有预览图而没有模型文件时不能伪装成 GLB 成功。 -9. 调用现有 SpacetimeDB compile procedure 写入草稿,并把本次生成的独立物品图片和模型列表序列化写入 `match3d_work_profile.generated_item_assets_json`。这一步对标拼图的 `save_puzzle_generated_images`:生成资产不能只挂在本次 HTTP response 上,否则退出结果页后从草稿架读取 `getMatch3DWorkDetail` 会丢失素材列表。 -10. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息,模型生成成功的素材状态为 `model_ready`;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材。 +3. 先调用 SpacetimeDB compile procedure 写入草稿。首次执行使用新 `profileId`;重试时复用 session draft / work profile 中已有 `profileId`。这一步不能等待 LLM、图片、OSS 或 Rodin 成功后才执行。 +4. 基于入口页题材设定文本调用文本模型生成作品元信息。模型固定请求 `gpt-4o`,只返回 JSON,其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写。文本模型不可用时保留第 3 步的本地兜底,不阻断草稿。 +5. 调用文本模型生成 `3` 个题材下的短物品名称。 +6. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine,不重新接入 APIMart 图片网关。 +7. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3` 格。 +8. 将素材图和每张独立图片上传到 OSS,其中独立图片作为草稿页素材预览和 Rodin 图生模型参考图;每次获得可恢复的图片资产后,都要回写 `match3d_work_profile.generated_item_assets_json`。 +9. 使用每张独立图片作为参考图,并行调用 Hyper3D Rodin 图生模型;所有 3D 模型任务必须在同一阶段同时提交、同时轮询状态、同时下载并转存 OSS,禁止逐个物品串行等待模型完成。每个任务按官方 `check-status_reset_v` / `download-results_reset_v` 文档轮询状态和下载:状态查询使用 `subscription_key`,整体完成态以 `jobs[]` 聚合为准;下载查询使用生成响应顶层 `uuid` 作为 `task_uuid`,不能使用 `jobs.uuids` 子任务 uuid。只有 `jobs` 全部进入 `Done` 才能视为任务完成,任一 job `Failed` 则失败。完成后选择 `.glb` 下载文件,并把 GLB 转存到 OSS。Rodin 的 `subscriptionKey` 是上游 opaque token,不做 256 字符这类短文本长度限制。Rodin 任务状态进入完成态后,下载列表仍可能延迟发布;后端必须对下载列表继续轮询,并兼容 `url`、`downloadUrl`、`fileUrl`、`signedUrl` 等下载字段别名,只有预览图而没有模型文件时不能伪装成 GLB 成功。 +10. Rodin 每批完成后继续回写 `generated_item_assets_json`。成功素材状态为 `model_ready`;失败素材保留图片引用并记录 `error`,下次 `match3d_compile_draft` 只继续缺失模型的素材,不重复生成已完成的 GLB。 +11. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材。 若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅` 与本地标签兜底,不阻断素材生成;但描述仍保持空字符串。 -草稿生成阶段会调用 Hyper3D Rodin 并等待 GLB 下载完成;前端 `match3d_compile_draft` action 请求超时必须覆盖该长耗时链路,当前 Match3D client 使用 20 分钟超时。Rodin 单模型状态轮询预算为 10 分钟,下载列表发布轮询预算为 5 分钟;由于 3 个模型并行生成,总耗时按最慢模型计算,不能按模型数量线性叠加。结果页 `3D素材` Tab 直接加载已生成模型;用户点击 `重新生成` 时再复用 Rodin 安全代理,首版重新生成只更新当前页面内预览状态,后续正式资产绑定以独立技术方案为准。 +草稿生成阶段会调用 Hyper3D Rodin 并等待 GLB 下载完成;前端 `match3d_compile_draft` action 请求超时必须覆盖该长耗时链路,当前 Match3D client 使用 20 分钟超时。Rodin 单模型状态轮询预算为 10 分钟,下载列表发布轮询预算为 5 分钟;GLB 下载和 OSS PutObject 各自设置 3 分钟 HTTP 超时,避免上游下载或转存连接长期悬挂。由于 3 个模型并行生成,总耗时按最慢模型计算,不能按模型数量线性叠加。结果页 `3D素材` Tab 直接加载已生成模型;用户点击 `重新生成` 时再复用 Rodin 安全代理,首版重新生成只更新当前页面内预览状态,后续正式资产绑定以独立技术方案为准。 ## 4. 图片提示词 @@ -113,9 +117,18 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard 5. 物理碰撞和边界仍沿用现有 `visualKey` 的程序化几何,生成 GLB 只替换视觉模型,不承接规则真相。 6. 模型缺失、读取失败或 WebGL 回退时,继续使用默认积木素材,不能阻断开局、点击、入槽或结算。 +结果页点击 `试玩` 时,前端必须把当前结果页可见的 `generatedItemAssets` 带入运行态启动入参。`PUT /api/runtime/match3d/works/{profileId}` 若因为并发或旧快照返回了缺少素材的 profile,`Match3DResultView` 需要把当前 draft / profile 的素材重新合并到 `onStartTestRun(profile)`;若历史草稿同时存在旧 `draft.generatedItemAssets` 和较新的 `profile.generatedItemAssets`,同 `itemId` 下以 profile 中已有的 `modelSrc` / `modelObjectKey` 补齐 draft,不能让旧 draft 把模型状态覆盖回 `image_ready`。`PlatformEntryFlowShellImpl` 在渲染 `match3d-runtime` 时按 `run.profileId` 优先使用当前 `match3dProfile.generatedItemAssets`,只有 profileId 不匹配时才读取 `selectedPublicWorkDetail.generatedItemAssets`。推荐流内嵌正式运行态也必须走同一解析器;当推荐卡片摘要缺少素材时,启动前补读 `getMatch3DWorkDetail(profileId)`,把详情里的生成模型写入 `match3dProfile` 后再传给运行态。这样可以避免从公开详情页残留状态或推荐卡片旧摘要进入试玩 / 正式游戏时,把已生成草稿的 3D 模型覆盖成空列表。 + ## 6. 自动保存与草稿恢复 -抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。 +点击 `生成抓大鹅草稿` 后,草稿存档创建与素材生成解耦: + +1. 首次 compile 必须先写 `match3d_work_profile` 草稿行,即使后续卡在文本模型、图片生成、OSS 上传、Rodin 生成或下载转存任意阶段。 +2. 失败态前端要重新读取 session / work detail,并刷新草稿作品架,保证用户离开生成页后仍能在草稿 Tab 找到这份作品。 +3. 重新生成时优先使用当前 session 的 `draft.profileId` 或 `publishedProfileId`,不得重新创建 session;后端读取同一 profile 的 `generated_item_assets_json` 后,只补齐缺失图片或缺失模型的阶段。 +4. 已有 `status = model_ready` 且带 `modelSrc` / `modelObjectKey` 的素材视为完成,不再重复调用 Rodin。 + +抓大鹅结果页的基础信息自动保存继续调用 `PUT /api/runtime/match3d/works/{profileId}` 更新名称、题材、描述、标签、封面、消除数和难度;该保存不得清空 `generated_item_assets_json`。结果页 `3D素材` Tab 手动点击 `重新生成` 并拿到 GLB 下载文件后,必须把当前素材草稿重新序列化成 `generatedItemAssets` 并写回作品 profile;否则页面内预览会显示新模型,但试玩、发布和重进草稿仍会读取旧的空模型快照。SpacetimeDB `update_match3d_work` / `publish_match3d_work` 必须保留当前行的生成素材 JSON。 草稿架重进路径为: @@ -123,7 +136,7 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard 草稿 Tab -> getMatch3DWorkDetail(profileId) -> Match3DResultView(profile.generatedItemAssets) ``` -因此 `map_match3d_work_summary_response` / `map_match3d_work_profile_response` 需要从 work profile snapshot 反序列化 `generated_item_assets_json` 并输出 `generatedItemAssets`。前端 `Match3DResultView` 仍保持现有优先级:本次生成流程内有 `draft.generatedItemAssets` 时用 draft;从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认 3D 素材占位。 +因此 `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` 中已有模型字段时,用 profile 模型字段补齐 draft;从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认 3D 素材占位。 结果页 `作品信息` Tab 字段命名对齐拼图草稿: @@ -134,7 +147,7 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard `3D素材` 详情页只保留: -1. 模型预览区:优先加载 `modelSrc` 对应 GLB,支持拖动旋转;没有模型时展示空预览。 +1. 模型预览区:优先加载 `modelSrc` 对应 GLB,缺失时加载 `modelObjectKey`,支持拖动旋转;没有模型时展示空预览。 2. 素材名称输入。 3. `重新生成` 按钮。 @@ -148,6 +161,8 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard npm run check:encoding npm run test -- src\services\miniGameDraftGenerationProgress.test.ts npm run test -- src\components\match3d-result\Match3DResultView.test.tsx +npm run test -- src\components\match3d-runtime\Match3DRuntimeShell.test.tsx +npm run test -- src\components\rpg-entry\RpgEntryFlowShell.agent.interaction.test.tsx npm run typecheck cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml cargo test -p spacetime-client match3d --manifest-path server-rs\Cargo.toml diff --git a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md index 1fff2a68..c7c5439b 100644 --- a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md +++ b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md @@ -8,7 +8,7 @@ 1. 新建作品入口配置统一存放在 SpacetimeDB 的 `creation_entry_config` / `creation_entry_type_config` 表;默认种子位于 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`。 2. `visible` 控制玩法是否展示在创作 Tab 模板入口、新建作品入口和创作类型弹层中。 -3. `open` 控制玩法是否允许点击创建以及对应 runtime/API 路由是否放行;`open: false` 时入口保持展示但禁用,并由 `api-server` 熔断对应玩法 API。 +3. `open` 控制玩法是否允许点击创建以及对应创作 / runtime API 路由是否放行;`open: false` 时入口保持展示但禁用,并由 `api-server` 熔断对应玩法 API。 4. `title`、`subtitle`、`badge` 控制玩法卡片文案。 5. `startCard` 控制旧创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案;当前创作 Tab 首屏标题固定在 `PlatformEntryFlowShellImpl.tsx`,不再由 `startCard` 控制。 6. `typeModal` 控制平台创作类型弹层标题和描述。 @@ -26,7 +26,7 @@ | 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Match3D Agent 共创工作台 | | 方洞挑战 | 否 | 是 | 创作页入口暂时完全隐藏,既有草稿、结果页、发布、试玩、作品架与广场链路保留 | | AIRP | 是 | 否 | 保留入口,显示敬请期待 | -| 视觉小说 | 是 | 是 | 点击后进入视觉小说创作工作台 | +| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待,暂不允许创建视觉小说草稿 | | 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 | ## 验收 diff --git a/docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md b/docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md new file mode 100644 index 00000000..42326afc --- /dev/null +++ b/docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md @@ -0,0 +1,89 @@ +# 拼图与抓大鹅结果页音乐 Tab 2026-05-11 + +## 1. 范围 + +本方案把 VectorEngine 音频生成能力从视觉小说结果页扩展到拼图与抓大鹅结果页: + +1. 拼图结果页新增 `音乐` Tab,支持通过 Suno 生成作品背景音乐。 +2. 抓大鹅结果页新增 `音乐` Tab,支持通过 Suno 生成作品背景音乐。 +3. 抓大鹅 `3D素材` Tab 支持为每个生成物体通过 Vidu 生成点击音效。 + +本轮不新增 SpacetimeDB 表,不修改表字段,不把供应商密钥下发到前端。 + +## 2. 通用音频接口 + +后端在既有视觉小说音频路由外新增通用创作音频路由: + +| 方法 | 路由 | 用途 | +| --- | --- | --- | +| `POST` | `/api/creation/audio/background-music` | 提交 Suno 背景音乐任务 | +| `POST` | `/api/creation/audio/background-music/{task_id}/asset` | 查询并转存 Suno 音频资产 | +| `POST` | `/api/creation/audio/sound-effect` | 提交 Vidu 音效任务 | +| `POST` | `/api/creation/audio/sound-effect/{task_id}/asset` | 查询并转存 Vidu 音效资产 | + +通用转存请求由前端传入 `entityKind`、`entityId`、`slot`、`assetKind`、`profileId`。后端仍负责: + +1. 校验 VectorEngine 与 OSS 环境变量。 +2. 轮询供应商任务结果。 +3. 下载音频字节。 +4. 写入 OSS 私有对象。 +5. 确认 `asset_object` 并绑定 `asset_entity_binding`。 + +视觉小说原路由保持兼容,内部继续复用同一套提交、轮询、转存逻辑。 + +## 3. 数据落点 + +### 3.1 拼图 + +拼图作品没有独立作品级 metadata 字段。背景音乐随 `levels_json` 保存到首个 `PuzzleDraftLevel.backgroundMusic`: + +```json +{ + "levelId": "puzzle-level-1", + "backgroundMusic": { + "taskId": "suno-task", + "provider": "vector-engine-suno", + "assetObjectId": "assetobj_1", + "assetKind": "puzzle_background_music", + "audioSrc": "/generated-puzzle-assets/..." + } +} +``` + +运行态后续可从当前关卡快照或作品详情读取该字段作为背景音乐源;若字段为空,继续使用现有程序化背景音乐兜底。 + +### 3.2 抓大鹅 + +抓大鹅作品级音频与物体点击音效复用 `generated_item_assets_json` 数组保存,不新增表字段: + +1. 作品背景音乐暂存到第一个 `Match3DGeneratedItemAsset.backgroundMusic`,表示当前 work profile 的作品级背景音乐。 +2. 单个物体点击音效保存到对应 `Match3DGeneratedItemAsset.clickSound`。 + +这是一个兼容性折中:当前 Match3D work profile 没有 work-level metadata 字段,而 `generated_item_assets_json` 已经随作品详情、草稿架、运行态入口稳定传递。后续若新增正式作品 metadata 表达,应迁移 `backgroundMusic` 到作品级字段。 + +## 4. 前端交互 + +结果页 UI 保持轻量: + +1. `音乐` Tab 只展示必要输入、生成按钮、状态与音频预览,不展示供应商规则说明。 +2. 生成完成后立即写回本地草稿状态,并触发既有保存链路或专用保存接口。 +3. 抓大鹅每个物体音效生成入口放在对应素材详情面板内,不在列表下方展开大段配置。 + +## 5. 验收 + +建议执行: + +```powershell +npm run check:encoding +npm run test -- src\components\puzzle-result\PuzzleResultView.test.tsx +npm run test -- src\components\match3d-result\Match3DResultView.test.tsx +npm run typecheck +cargo test -p shared-contracts creation_audio --manifest-path server-rs\Cargo.toml +cargo test -p shared-contracts puzzle --manifest-path server-rs\Cargo.toml +cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml +cargo test -p api-server vector_engine_audio_generation --manifest-path server-rs\Cargo.toml +cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml +cargo check -p api-server --manifest-path server-rs\Cargo.toml +``` + +真实生成 smoke 需要本地私密环境配置 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 与 OSS 变量。后端改动后使用 `npm run api-server` 启动,并确认 `/healthz`。 diff --git a/packages/shared/src/contracts/creationAudio.ts b/packages/shared/src/contracts/creationAudio.ts new file mode 100644 index 00000000..e1ad6cc3 --- /dev/null +++ b/packages/shared/src/contracts/creationAudio.ts @@ -0,0 +1,53 @@ +export type CreationAudioGenerationKind = + | 'background_music' + | 'sound_effect'; + +export interface CreationAudioAsset { + taskId: string; + provider: string; + assetObjectId?: string | null; + assetKind?: string | null; + audioSrc: string; + prompt?: string | null; + title?: string | null; + updatedAt?: string | null; +} + +export interface CreateBackgroundMusicRequest { + prompt: string; + title: string; + tags?: string | null; + model?: string | null; +} + +export interface CreateSoundEffectRequest { + prompt: string; + duration?: number | null; + seed?: number | null; +} + +export interface AudioGenerationTaskResponse { + kind: CreationAudioGenerationKind; + taskId: string; + provider: string; + status: string; +} + +export interface PublishGeneratedAudioAssetRequest { + entityKind: string; + entityId: string; + slot: string; + assetKind: string; + profileId?: string | null; + storagePrefix?: 'puzzle_assets' | 'match3d_assets' | 'custom_world_scenes' | null; +} + +export interface GeneratedAudioAssetResponse { + kind: CreationAudioGenerationKind; + taskId: string; + provider: string; + status: string; + assetObjectId?: string | null; + assetKind?: string | null; + audioSrc?: string | null; +} diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 9ba7bd91..fade9a2a 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,4 +1,5 @@ export type * from './creativeAgent'; +export type * from './creationAudio'; export type * from './hyper3d'; export type * from './puzzleCreativeTemplate'; export type * from './visualNovel'; diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts index 83ec0fa5..b6505c1b 100644 --- a/packages/shared/src/contracts/match3dWorks.ts +++ b/packages/shared/src/contracts/match3dWorks.ts @@ -2,6 +2,8 @@ * 抓大鹅 Match3D 作品读写共享契约。 * 首版作品发布必须补齐游戏名称、标签、封面、题材、消除次数和难度。 */ +import type { CreationAudioAsset } from './creationAudio'; + export type Match3DWorkPublicationStatus = 'draft' | 'published' | string; export type Match3DGeneratedItemAssetStatus = @@ -22,10 +24,16 @@ export interface Match3DGeneratedItemAsset { modelFileName?: string | null; taskUuid?: string | null; subscriptionKey?: string | null; + backgroundMusic?: CreationAudioAsset | null; + clickSound?: CreationAudioAsset | null; status: Match3DGeneratedItemAssetStatus; error?: string | null; } +export interface PutMatch3DAudioAssetsRequest { + generatedItemAssets: Match3DGeneratedItemAsset[]; +} + export interface PutMatch3DWorkRequest { gameName: string; themeText?: string; diff --git a/packages/shared/src/contracts/puzzleAgentDraft.ts b/packages/shared/src/contracts/puzzleAgentDraft.ts index 203ace00..91d6f5f8 100644 --- a/packages/shared/src/contracts/puzzleAgentDraft.ts +++ b/packages/shared/src/contracts/puzzleAgentDraft.ts @@ -1,4 +1,5 @@ import type { JsonObject } from './common'; +import type { CreationAudioAsset } from './creationAudio'; export type PuzzleAnchorStatus = | 'missing' @@ -47,6 +48,7 @@ export interface PuzzleDraftLevel { levelName: string; pictureDescription: string; pictureReference?: string | null; + backgroundMusic?: CreationAudioAsset | null; candidates: PuzzleGeneratedImageCandidate[]; selectedCandidateId: string | null; coverImageSrc: string | null; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5012b0bd..523ae03d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,6 +3,7 @@ export * from './contracts/auth'; export type * from './contracts/bigFish'; export * from './contracts/common'; export type * from './contracts/creationAgentDocumentInput'; +export type * from './contracts/creationAudio'; export type * from './contracts/creativeAgent'; export type * from './contracts/customWorldAgent'; export type * from './contracts/hyper3d'; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 51132c88..048c8721 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -5,7 +5,7 @@ use axum::{ http::Request, middleware, response::Response, - routing::{delete, get, post}, + routing::{delete, get, post, put}, }; use tower_http::{ classify::ServerErrorsFailureClass, @@ -92,8 +92,8 @@ use crate::{ delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up, generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works, list_match3d_gallery, publish_match3d_work, - put_match3d_work, restart_match3d_run, start_match3d_run, stop_match3d_run, - stream_match3d_agent_message, submit_match3d_agent_message, + put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run, + stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message, }, password_entry::password_entry, password_management::{change_password, reset_password}, @@ -156,7 +156,9 @@ use crate::{ }, tracking::record_route_tracking_event_after_success, vector_engine_audio_generation::{ + create_background_music_task, create_sound_effect_task, create_visual_novel_background_music_task, create_visual_novel_sound_effect_task, + publish_background_music_asset, publish_sound_effect_asset, publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset, }, visual_novel::{ @@ -942,6 +944,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/creation/match3d/works/{profile_id}/audio-assets", + put(put_match3d_audio_assets).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/creation/match3d/works/{profile_id}/publish", post(publish_match3d_work).route_layer(middleware::from_fn_with_state( @@ -1517,7 +1526,7 @@ pub fn build_router(state: AppState) -> Router { )), ) .route("/api/auth/password/reset", post(reset_password)) - // 后端 runtime/API 路由只按 open 做熔断;visible 仅控制创作页入口展示。 + // 后端创作/运行态 API 路由只按 open 做熔断;visible 仅控制创作页入口展示。 .layer(middleware::from_fn_with_state( state.clone(), require_creation_entry_route_enabled, @@ -1770,6 +1779,34 @@ fn visual_novel_router(state: AppState) -> Router { middleware::from_fn_with_state(state.clone(), require_bearer_auth), ), ) + .route( + "/api/creation/audio/background-music", + post(create_background_music_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/audio/background-music/{task_id}/asset", + post(publish_background_music_asset).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/audio/sound-effect", + post(create_sound_effect_task).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/audio/sound-effect/{task_id}/asset", + post(publish_sound_effect_asset).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/visual-novel/gallery", get(list_visual_novel_gallery), @@ -2007,6 +2044,38 @@ mod tests { assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle"); } + #[tokio::test] + async fn disabled_visual_novel_creation_route_returns_service_unavailable() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/creation/visual-novel/sessions") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "sourceMode": "idea", + "seedText": "雨夜书店", + "sourceAssetIds": [] + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = read_json_response(response).await; + assert_eq!( + body["error"]["details"]["reason"], + "creation_entry_disabled" + ); + assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel"); + } + #[tokio::test] async fn healthz_returns_standard_envelope_when_requested() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -4693,7 +4762,9 @@ mod tests { #[tokio::test] async fn visual_novel_creation_route_requires_authentication() { - let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let state = AppState::new(AppConfig::default()).expect("state should build"); + state.set_test_creation_entry_route_enabled("visual-novel", true); + let app = build_router(state); let response = app .oneshot( diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 380bc62c..2efa038d 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -34,7 +34,7 @@ pub async fn get_creation_entry_config_handler( Ok(json_success_body(Some(&request_context), config)) } -/// 中文注释:api-server 路由熔断只拦运行态/API 请求,不改变前端入口展示规则。 +/// 中文注释:api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则。 pub async fn require_creation_entry_route_enabled( State(state): State, request: Request, @@ -87,6 +87,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { if normalized.starts_with("/api/runtime/visual-novel") { return Some("visual-novel"); } + if normalized.starts_with("/api/creation/visual-novel") { + return Some("visual-novel"); + } None } @@ -115,7 +118,7 @@ pub(crate) fn test_creation_entry_config_response() test_creation_type("puzzle", true, true, 30), test_creation_type("match3d", true, true, 40), test_creation_type("square-hole", false, true, 50), - test_creation_type("visual-novel", true, true, 60), + test_creation_type("visual-novel", true, false, 60), test_creation_type("airp", true, false, 70), test_creation_type("creative-agent", false, true, 80), ], @@ -165,6 +168,10 @@ mod tests { resolve_creation_entry_route_id("/api/runtime/visual-novel/works"), Some("visual-novel"), ); + assert_eq!( + resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"), + Some("visual-novel"), + ); assert_eq!(resolve_creation_entry_route_id("/healthz"), None); } } diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index ae936851..02bf8d05 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -89,6 +89,14 @@ impl IntoResponse for AppError { } } +impl std::fmt::Display for AppError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.body_text().as_str()) + } +} + +impl std::error::Error for AppError {} + impl From for Response { fn from(error: AppError) -> Self { error.into_response() diff --git a/server-rs/crates/api-server/src/hyper3d_generation.rs b/server-rs/crates/api-server/src/hyper3d_generation.rs index c3ca23b4..b5d8b691 100644 --- a/server-rs/crates/api-server/src/hyper3d_generation.rs +++ b/server-rs/crates/api-server/src/hyper3d_generation.rs @@ -563,11 +563,9 @@ fn resolve_hyper3d_overall_status( fn extract_job_uuids(payload: &Value) -> Vec { let mut job_uuids = Vec::new(); - if let Some(jobs) = find_first_array_by_keys(payload, &["jobs"]) { - for job in jobs { - if let Some(uuid) = find_first_string_by_keys(job, &["uuid", "task_uuid", "taskUuid"]) - && !job_uuids.contains(&uuid) - { + if let Some(jobs) = payload.get("jobs") { + for uuid in collect_strings_by_keys(jobs, &["uuid", "task_uuid", "taskUuid", "uuids"]) { + if !job_uuids.contains(&uuid) { job_uuids.push(uuid); } } @@ -1076,8 +1074,10 @@ mod tests { contract::Hyper3dGenerationMode::TextToModel, json!({ "uuid": "task-1", - "subscription_key": "sub-1", - "jobs": [{ "uuid": "job-1" }], + "jobs": { + "uuids": ["job-1", "job-2"], + "subscription_key": "sub-1" + }, "message": "submitted" }), ) @@ -1085,7 +1085,7 @@ mod tests { assert_eq!(response.task_uuid, "task-1"); assert_eq!(response.subscription_key, "sub-1"); - assert_eq!(response.job_uuids, vec!["job-1"]); + assert_eq!(response.job_uuids, vec!["job-1", "job-2"]); } #[test] diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index c0b9b72d..91d24ecd 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -14,7 +14,7 @@ use axum::{ }, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use futures_util::future::try_join_all; +use futures_util::{StreamExt, stream::FuturesUnordered}; use image::{GenericImageView, ImageFormat}; use module_match3d::{ MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX, @@ -41,7 +41,8 @@ use shared_contracts::{ }, match3d_works::{ Match3DWorkDetailResponse, Match3DWorkMutationResponse, Match3DWorkProfileResponse, - Match3DWorkSummaryResponse, Match3DWorksResponse, PutMatch3DWorkRequest, + Match3DWorkSummaryResponse, Match3DWorksResponse, PutMatch3DAudioAssetsRequest, + PutMatch3DWorkRequest, }, }; use shared_kernel::build_prefixed_uuid_id; @@ -84,7 +85,10 @@ const MATCH3D_RODIN_STATUS_MAX_ATTEMPTS: usize = 120; const MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS: u64 = 5_000; const MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS: usize = 60; const MATCH3D_RODIN_DOWNLOAD_POLL_INTERVAL_MS: u64 = 5_000; +const MATCH3D_RODIN_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000; +const MATCH3D_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000; const MATCH3D_RODIN_MAX_MODEL_BYTES: usize = 120 * 1024 * 1024; +const MATCH3D_ITEM_IMAGE_MAX_BYTES: usize = 20 * 1024 * 1024; const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o"; const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材"; const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关"; @@ -116,6 +120,8 @@ struct Match3DGeneratedItemAsset { model_file_name: Option, task_uuid: Option, subscription_key: Option, + background_music: Option, + click_sound: Option, status: String, error: Option, } @@ -145,6 +151,10 @@ struct Match3DGeneratedItemAssetJson { task_uuid: Option, #[serde(default)] subscription_key: Option, + #[serde(default)] + background_music: Option, + #[serde(default)] + click_sound: Option, status: String, #[serde(default)] error: Option, @@ -163,6 +173,8 @@ struct Match3DGeneratedItemModelSeed { item_slug: String, image_upload: Match3DAssetUpload, image_bytes: Vec, + background_music: Option, + click_sound: Option, generated_at_micros: i64, } @@ -604,6 +616,84 @@ pub async fn put_match3d_work( )) } +pub async fn put_match3d_audio_assets( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let existing = state + .spacetime_client() + .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let session_id = existing.source_session_id.clone().ok_or_else(|| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "message": "抓大鹅作品缺少来源 session,无法写回音频素材", + })), + ) + })?; + let assets = payload + .generated_item_assets + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect::>(); + let session = upsert_match3d_draft_snapshot( + &state, + &request_context, + &authenticated, + session_id, + owner_user_id.clone(), + profile_id.clone(), + Some(existing.game_name), + Some(existing.summary), + Some(serde_json::to_string(&existing.tags).unwrap_or_default()), + existing.cover_image_src, + None, + serialize_match3d_generated_item_assets(&assets), + ) + .await?; + + let item = state + .spacetime_client() + .get_match3d_work_detail(profile_id, owner_user_id) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let _ = session; + Ok(json_success_body( + Some(&request_context), + Match3DWorkMutationResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + pub async fn generate_match3d_work_tags( State(state): State, Extension(request_context): Extension, @@ -1021,7 +1111,7 @@ async fn compile_match3d_draft_for_session( cover_image_src: Option, ) -> Result<(Match3DAgentSessionRecord, Vec), Response> { let owner_user_id = authenticated.claims().user_id().to_string(); - let session = state + let initial_session = state .spacetime_client() .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) .await @@ -1032,14 +1122,16 @@ async fn compile_match3d_draft_for_session( map_match3d_client_error(error), ) })?; - let mut config = resolve_config_or_default(session.config.as_ref()); + let mut config = resolve_config_or_default(initial_session.config.as_ref()); config.clear_count = MATCH3D_GENERATED_CLEAR_COUNT; // 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session // 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。 let has_complete_form_config = !config.theme_text.trim().is_empty() && config.clear_count > 0 && (1..=10).contains(&config.difficulty); - if !has_complete_form_config && (session.current_turn < 3 || session.progress_percent < 100) { + if !has_complete_form_config + && (initial_session.current_turn < 3 || initial_session.progress_percent < 100) + { return Err(match3d_bad_request( request_context, MATCH3D_AGENT_PROVIDER, @@ -1047,41 +1139,128 @@ async fn compile_match3d_draft_for_session( )); } - let profile_id = build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX); + let requested_game_name = normalize_optional_match3d_text(game_name); + let requested_summary = + normalize_optional_match3d_text(summary).or_else(|| Some(String::new())); + let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty()); + let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); + let profile_id = resolve_match3d_draft_profile_id(&initial_session); + let initial_game_name = requested_game_name + .clone() + .unwrap_or_else(|| fallback_work_metadata.game_name.clone()); + let initial_tags = requested_tags + .clone() + .unwrap_or_else(|| fallback_work_metadata.tags.clone()); + let mut session = upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id.clone(), + owner_user_id.clone(), + profile_id.clone(), + Some(initial_game_name), + requested_summary.clone(), + Some(serde_json::to_string(&initial_tags).unwrap_or_default()), + cover_image_src.clone(), + None, + None, + ) + .await?; + + if session.draft.is_none() { + return Err(match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"), + )); + } + let generated_work_metadata = generate_match3d_work_metadata(state, &config).await; + let resolved_game_name = requested_game_name.unwrap_or(generated_work_metadata.game_name); + let resolved_tags = requested_tags.unwrap_or(generated_work_metadata.tags); + session = upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id.clone(), + profile_id.clone(), + Some(resolved_game_name), + requested_summary, + Some(serde_json::to_string(&resolved_tags).unwrap_or_default()), + cover_image_src, + None, + None, + ) + .await?; + + let existing_assets = get_match3d_existing_generated_item_assets( + state, + owner_user_id.as_str(), + profile_id.as_str(), + ) + .await; let generated_item_assets = generate_match3d_item_assets( state, request_context, + authenticated, owner_user_id.as_str(), session.session_id.as_str(), profile_id.as_str(), &config, + existing_assets, ) .await?; - let resolved_game_name = - normalize_optional_match3d_text(game_name).unwrap_or(generated_work_metadata.game_name); - let resolved_tags = tags - .map(normalize_tags) - .filter(|items| !items.is_empty()) - .unwrap_or(generated_work_metadata.tags); - let tags_json = Some(serde_json::to_string(&resolved_tags).unwrap_or_default()); - let session = state + Ok((session, generated_item_assets)) +} + +fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String { + session + .draft + .as_ref() + .map(|draft| draft.profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .or_else(|| { + session + .published_profile_id + .as_deref() + .map(str::trim) + .filter(|profile_id| !profile_id.is_empty()) + }) + .map(str::to_string) + .unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX)) +} + +#[allow(clippy::too_many_arguments)] +async fn upsert_match3d_draft_snapshot( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: String, + owner_user_id: String, + profile_id: String, + game_name: Option, + summary_text: Option, + tags_json: Option, + cover_image_src: Option, + cover_asset_id: Option, + generated_item_assets_json: Option, +) -> Result { + state .spacetime_client() .compile_match3d_draft(Match3DCompileDraftRecordInput { session_id, owner_user_id, profile_id, author_display_name: resolve_author_display_name(state, authenticated), - game_name: Some(resolved_game_name), - summary_text: normalize_optional_match3d_text(summary).or_else(|| Some(String::new())), + game_name, + summary_text, tags_json, cover_image_src, - cover_asset_id: None, + cover_asset_id, compiled_at_micros: current_utc_micros(), - generated_item_assets_json: serialize_match3d_generated_item_assets( - &generated_item_assets, - ), + generated_item_assets_json, }) .await .map_err(|error| { @@ -1090,9 +1269,63 @@ async fn compile_match3d_draft_for_session( MATCH3D_AGENT_PROVIDER, map_match3d_client_error(error), ) - })?; + }) +} - Ok((session, generated_item_assets)) +async fn get_match3d_existing_generated_item_assets( + state: &AppState, + owner_user_id: &str, + profile_id: &str, +) -> Vec { + match state + .spacetime_client() + .get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string()) + .await + { + Ok(profile) => { + parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref()) + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect() + } + Err(error) => { + tracing::debug!( + provider = MATCH3D_AGENT_PROVIDER, + profile_id, + error = %error, + "读取抓大鹅已有素材失败,按空素材继续生成" + ); + Vec::new() + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn persist_match3d_generated_item_assets_snapshot( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: &str, + owner_user_id: &str, + profile_id: &str, + assets: &[Match3DGeneratedItemAsset], +) -> Result<(), Response> { + upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id.to_string(), + owner_user_id.to_string(), + profile_id.to_string(), + None, + None, + None, + None, + None, + serialize_match3d_generated_item_assets(assets), + ) + .await + .map(|_| ()) } fn map_match3d_agent_session_response( @@ -1237,6 +1470,8 @@ fn map_match3d_generated_item_asset_for_agent( model_file_name: asset.model_file_name, task_uuid: asset.task_uuid, subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, status: asset.status, error: asset.error, } @@ -1255,6 +1490,8 @@ fn map_match3d_generated_item_asset_for_work( model_file_name: asset.model_file_name, task_uuid: asset.task_uuid, subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, status: asset.status, error: asset.error, } @@ -1769,6 +2006,50 @@ impl From for Match3DGeneratedItemAssetJson { model_file_name: asset.model_file_name, task_uuid: asset.task_uuid, subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, + status: asset.status, + error: asset.error, + } + } +} + +impl From for Match3DGeneratedItemAsset { + fn from(asset: Match3DGeneratedItemAssetJson) -> Self { + Self { + item_id: asset.item_id, + item_name: asset.item_name, + image_src: asset.image_src, + image_object_key: asset.image_object_key, + model_src: asset.model_src, + model_object_key: asset.model_object_key, + model_file_name: asset.model_file_name, + task_uuid: asset.task_uuid, + subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, + status: asset.status, + error: asset.error, + } + } +} + +impl From + for Match3DGeneratedItemAsset +{ + fn from(asset: shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse) -> Self { + Self { + item_id: asset.item_id, + item_name: asset.item_name, + image_src: asset.image_src, + image_object_key: asset.image_object_key, + model_src: asset.model_src, + model_object_key: asset.model_object_key, + model_file_name: asset.model_file_name, + task_uuid: asset.task_uuid, + subscription_key: asset.subscription_key, + background_music: asset.background_music, + click_sound: asset.click_sound, status: asset.status, error: asset.error, } @@ -1792,12 +2073,78 @@ fn resolve_author_display_name( async fn generate_match3d_item_assets( state: &AppState, request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, owner_user_id: &str, session_id: &str, profile_id: &str, config: &Match3DConfigJson, + existing_assets: Vec, ) -> Result, Response> { // 中文注释:外部模型、下载和 OSS 写入都留在 api-server,SpacetimeDB reducer 只保存确定性草稿。 + if existing_assets + .iter() + .filter(|asset| is_match3d_generated_asset_model_ready(asset)) + .count() + >= MATCH3D_GENERATED_ITEM_COUNT + { + return Ok(sort_match3d_generated_assets(existing_assets) + .into_iter() + .take(MATCH3D_GENERATED_ITEM_COUNT) + .collect()); + } + + let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); + if !has_match3d_required_item_images(&assets) { + assets = ensure_match3d_item_image_assets( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + assets, + ) + .await?; + } + + let assets = fill_match3d_item_models( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + assets, + ) + .await?; + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + + Ok(assets) +} + +#[allow(clippy::too_many_arguments)] +async fn ensure_match3d_item_image_assets( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + existing_assets: Vec, +) -> Result, Response> { + let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); let item_names = generate_match3d_item_names(state, config) .await .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; @@ -1823,17 +2170,19 @@ async fn generate_match3d_item_assets( let item_images = slice_match3d_material_sheet(&material_sheet.image, &item_names) .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - let mut model_seeds = Vec::with_capacity(item_images.len()); for (index, item_image) in item_images.into_iter().enumerate() { let item_name = item_names .get(index) .cloned() .unwrap_or_else(|| format!("物品{}", index + 1)); let item_id = format!("match3d-item-{}", index + 1); - let item_slug = format!( - "{item_id}-{}", - sanitize_match3d_asset_segment(&item_name, "item") - ); + if assets + .iter() + .any(|asset| asset.item_id == item_id && is_match3d_generated_asset_image_ready(asset)) + { + continue; + } + let item_slug = build_match3d_item_slug(item_id.as_str(), item_name.as_str()); let image_bytes = item_image.bytes; let image_upload = persist_match3d_generated_bytes( state, @@ -1850,29 +2199,106 @@ async fn generate_match3d_item_assets( ) .await .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; + upsert_match3d_generated_item_asset( + &mut assets, + Match3DGeneratedItemAsset { + item_id, + item_name, + image_src: Some(image_upload.src), + image_object_key: Some(image_upload.object_key), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + ); + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + } + + Ok(assets) +} + +#[allow(clippy::too_many_arguments)] +async fn fill_match3d_item_models( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + assets: Vec, +) -> Result, Response> { + let mut assets = normalize_match3d_generated_item_assets_for_resume(assets); + let mut model_seeds = Vec::new(); + for (index, asset) in assets.iter().enumerate() { + if is_match3d_generated_asset_model_ready(asset) { + continue; + } + let Some(image_object_key) = asset.image_object_key.as_deref() else { + continue; + }; + let image_bytes = read_match3d_generated_object_bytes( + state, + image_object_key, + "读取抓大鹅物品参考图失败", + MATCH3D_ITEM_IMAGE_MAX_BYTES, + ) + .await + .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; + let item_slug = build_match3d_item_slug(asset.item_id.as_str(), asset.item_name.as_str()); model_seeds.push(Match3DGeneratedItemModelSeed { - item_id, - item_name, + item_id: asset.item_id.clone(), + item_name: asset.item_name.clone(), item_slug, - image_upload, + image_upload: Match3DAssetUpload { + src: asset + .image_src + .clone() + .unwrap_or_else(|| format!("/{image_object_key}")), + object_key: image_object_key.to_string(), + }, image_bytes, - generated_at_micros: generated_at_micros.saturating_add(100 + index as i64), + background_music: asset.background_music.clone(), + click_sound: asset.click_sound.clone(), + generated_at_micros: current_utc_micros().saturating_add(100 + index as i64), }); } + if model_seeds.is_empty() { + return Ok(assets); + } + // 中文注释:Rodin 单个模型耗时不可控,必须在图片切割和入库后并行提交所有图生模型, // 避免多个物品的排队和轮询时间串行叠加导致 action 超时。 - let model_results = try_join_all(model_seeds.into_iter().map(|seed| { - async move { + let mut model_tasks = model_seeds + .into_iter() + .map(|seed| async move { let Match3DGeneratedItemModelSeed { item_id, item_name, item_slug, image_upload, image_bytes, + background_music, + click_sound, generated_at_micros, } = seed; - let model_asset = generate_match3d_rodin_model_asset( + let model_result = generate_match3d_rodin_model_asset( state, owner_user_id, session_id, @@ -1883,28 +2309,82 @@ async fn generate_match3d_item_assets( image_bytes, generated_at_micros, ) - .await?; + .await; - Ok::<_, AppError>(Match3DGeneratedItemAsset { - item_id, - item_name, - image_src: Some(image_upload.src), - image_object_key: Some(image_upload.object_key), - model_src: Some(model_asset.upload.src), - model_object_key: Some(model_asset.upload.object_key), - model_file_name: Some(model_asset.model_file_name), - task_uuid: Some(model_asset.task_uuid), - subscription_key: Some(model_asset.subscription_key), - status: "model_ready".to_string(), - error: None, - }) - } - })) - .await - .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; + match model_result { + Ok(model_asset) => Match3DGeneratedItemAsset { + item_id, + item_name, + image_src: Some(image_upload.src), + image_object_key: Some(image_upload.object_key), + model_src: Some(model_asset.upload.src), + model_object_key: Some(model_asset.upload.object_key), + model_file_name: Some(model_asset.model_file_name), + task_uuid: Some(model_asset.task_uuid), + subscription_key: Some(model_asset.subscription_key), + background_music, + click_sound, + status: "model_ready".to_string(), + error: None, + }, + Err(error) => Match3DGeneratedItemAsset { + item_id, + item_name, + image_src: Some(image_upload.src), + image_object_key: Some(image_upload.object_key), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music, + click_sound, + status: "model_failed".to_string(), + error: Some(error.to_string()), + }, + } + }) + .collect::>(); + + while let Some(model_asset) = model_tasks.next().await { + upsert_match3d_generated_item_asset(&mut assets, model_asset); + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + } + + let failed_items = assets + .iter() + .filter(|asset| !is_match3d_generated_asset_model_ready(asset)) + .filter_map(|asset| { + asset + .error + .as_deref() + .map(str::trim) + .filter(|error| !error.is_empty()) + .map(|error| format!("{}:{error}", asset.item_name)) + }) + .collect::>(); + if !failed_items.is_empty() { + return Err(match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + match3d_bad_gateway(format!( + "抓大鹅 3D 模型生成未完成:{}", + failed_items.join(";") + )), + )); + } // 中文注释:草稿阶段必须同时产出 GLB 模型,结果页直接加载模型预览。 - Ok(model_results) + Ok(assets) } struct Match3DMaterialSheet { @@ -2081,6 +2561,102 @@ fn fallback_match3d_item_names(theme_text: &str) -> Vec { .collect() } +fn normalize_match3d_generated_item_assets_for_resume( + assets: Vec, +) -> Vec { + let mut normalized = Vec::new(); + for asset in sort_match3d_generated_assets(assets) { + if asset.item_id.trim().is_empty() + || normalized + .iter() + .any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id) + { + continue; + } + normalized.push(asset); + if normalized.len() >= MATCH3D_GENERATED_ITEM_COUNT { + break; + } + } + normalized +} + +fn sort_match3d_generated_assets( + mut assets: Vec, +) -> Vec { + assets.sort_by(|left, right| { + match3d_item_sort_index(left.item_id.as_str()) + .cmp(&match3d_item_sort_index(right.item_id.as_str())) + .then_with(|| left.item_id.cmp(&right.item_id)) + }); + assets +} + +fn match3d_item_sort_index(item_id: &str) -> u32 { + item_id + .rsplit('-') + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or(u32::MAX) +} + +fn is_match3d_generated_asset_model_ready(asset: &Match3DGeneratedItemAsset) -> bool { + asset.status == "model_ready" + && (asset + .model_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + || asset + .model_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some()) +} + +fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) -> bool { + asset + .image_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() +} + +fn has_match3d_required_item_images(assets: &[Match3DGeneratedItemAsset]) -> bool { + assets.len() >= MATCH3D_GENERATED_ITEM_COUNT + && assets + .iter() + .take(MATCH3D_GENERATED_ITEM_COUNT) + .all(is_match3d_generated_asset_image_ready) +} + +fn upsert_match3d_generated_item_asset( + assets: &mut Vec, + asset: Match3DGeneratedItemAsset, +) { + if let Some(current) = assets + .iter_mut() + .find(|candidate| candidate.item_id == asset.item_id) + { + *current = asset; + *assets = sort_match3d_generated_assets(std::mem::take(assets)); + return; + } + assets.push(asset); + *assets = sort_match3d_generated_assets(std::mem::take(assets)); +} + +fn build_match3d_item_slug(item_id: &str, item_name: &str) -> String { + format!( + "{}-{}", + sanitize_match3d_asset_segment(item_id, "match3d-item"), + sanitize_match3d_asset_segment(item_name, "item") + ) +} + async fn generate_match3d_material_sheet( state: &AppState, config: &Match3DConfigJson, @@ -2126,6 +2702,15 @@ async fn generate_match3d_rodin_model_asset( generated_at_micros: i64, ) -> Result { let image_data_url = build_match3d_png_data_url(&image_bytes); + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + image_bytes = image_bytes.len(), + "抓大鹅 Rodin 图生模型提交开始" + ); let submit_response = submit_image_to_model( state, hyper3d_contract::Hyper3dImageToModelRequest { @@ -2144,13 +2729,50 @@ async fn generate_match3d_rodin_model_asset( }, ) .await?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + task_uuid = submit_response.task_uuid.as_str(), + subscription_key_len = submit_response.subscription_key.len(), + job_count = submit_response.job_uuids.len(), + "抓大鹅 Rodin 图生模型提交完成" + ); - wait_for_match3d_rodin_model(state, submit_response.subscription_key.as_str(), item_name) - .await?; + wait_for_match3d_rodin_model( + state, + submit_response.subscription_key.as_str(), + submit_response.task_uuid.as_str(), + item_name, + ) + .await?; let model_file = wait_for_match3d_rodin_download_file(state, submit_response.task_uuid.as_str(), item_name) .await?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + task_uuid = submit_response.task_uuid.as_str(), + file_name = model_file.name.as_str(), + "抓大鹅 Rodin 下载文件已选中" + ); let downloaded_model = download_match3d_rodin_model(&model_file).await?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + task_uuid = submit_response.task_uuid.as_str(), + file_name = downloaded_model.file_name.as_str(), + bytes = downloaded_model.bytes.len(), + "抓大鹅 Rodin GLB 下载完成" + ); let uploaded_model = persist_match3d_generated_bytes( state, owner_user_id, @@ -2170,6 +2792,16 @@ async fn generate_match3d_rodin_model_asset( generated_at_micros, ) .await?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_slug, + item_name, + task_uuid = submit_response.task_uuid.as_str(), + object_key = uploaded_model.object_key.as_str(), + "抓大鹅 Rodin GLB 转存 OSS 完成" + ); Ok(Match3DRodinModelAsset { task_uuid: submit_response.task_uuid, @@ -2200,6 +2832,7 @@ fn build_match3d_rodin_model_prompt(config: &Match3DConfigJson, item_name: &str) async fn wait_for_match3d_rodin_model( state: &AppState, subscription_key: &str, + task_uuid: &str, item_name: &str, ) -> Result<(), AppError> { for attempt in 0..MATCH3D_RODIN_STATUS_MAX_ATTEMPTS { @@ -2210,8 +2843,31 @@ async fn wait_for_match3d_rodin_model( }, ) .await?; + let should_log_progress = attempt == 0 + || matches!(status_response.status.as_str(), "done" | "failed") + || (attempt + 1) % 12 == 0; + if should_log_progress { + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + item_name, + task_uuid, + attempt = attempt + 1, + status = status_response.status.as_str(), + job_count = status_response.jobs.len(), + "抓大鹅 Rodin 状态轮询返回" + ); + } match status_response.status.as_str() { - "done" => return Ok(()), + "done" => { + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + item_name, + task_uuid, + attempts = attempt + 1, + "抓大鹅 Rodin 任务状态完成" + ); + return Ok(()); + } "failed" => { let message = status_response .jobs @@ -2250,7 +2906,27 @@ async fn wait_for_match3d_rodin_download_file( }, ) .await?; + let should_log_progress = + attempt == 0 || !download_response.files.is_empty() || (attempt + 1) % 6 == 0; + if should_log_progress { + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + item_name, + task_uuid, + attempt = attempt + 1, + file_count = download_response.files.len(), + "抓大鹅 Rodin 下载列表轮询返回" + ); + } if let Some(model_file) = find_match3d_glb_download(&download_response.files) { + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + item_name, + task_uuid, + attempt = attempt + 1, + file_name = model_file.name.as_str(), + "抓大鹅 Rodin 下载列表已返回模型文件" + ); return Ok(model_file.clone()); } @@ -2311,7 +2987,18 @@ fn is_match3d_preview_or_image_download( async fn download_match3d_rodin_model( file: &hyper3d_contract::Hyper3dDownloadFilePayload, ) -> Result { - let response = reqwest::Client::new() + let http_client = reqwest::Client::builder() + .timeout(Duration::from_millis( + MATCH3D_RODIN_MODEL_DOWNLOAD_TIMEOUT_MS, + )) + .build() + .map_err(|error| match3d_bad_gateway(format!("构造 Rodin 模型下载客户端失败:{error}")))?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + file_name = file.name.as_str(), + "抓大鹅 Rodin GLB 下载开始" + ); + let response = http_client .get(file.url.as_str()) .send() .await @@ -2333,6 +3020,9 @@ async fn download_match3d_rodin_model( status.as_u16() ))); } + if !is_match3d_downloaded_model_payload(file.name.as_str(), content_type.as_str()) { + return Err(match3d_bad_gateway("Rodin 下载结果不是 GLB 模型文件")); + } if bytes.is_empty() || bytes.len() > MATCH3D_RODIN_MAX_MODEL_BYTES { return Err(match3d_bad_gateway("Rodin 模型内容为空或超过大小上限")); } @@ -2344,6 +3034,21 @@ async fn download_match3d_rodin_model( }) } +fn is_match3d_downloaded_model_payload(file_name: &str, content_type: &str) -> bool { + let normalized_file_name = file_name.to_ascii_lowercase(); + let normalized_content_type = content_type + .split(';') + .next() + .unwrap_or(content_type) + .trim() + .to_ascii_lowercase(); + normalized_file_name.ends_with(".glb") + || matches!( + normalized_content_type.as_str(), + "model/gltf-binary" | "application/octet-stream" + ) +} + fn normalize_match3d_model_file_name(raw: &str) -> String { let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim(); let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim(); @@ -2368,6 +3073,57 @@ fn normalize_match3d_model_content_type(raw: &str) -> String { "model/gltf-binary".to_string() } +async fn read_match3d_generated_object_bytes( + state: &AppState, + object_key: &str, + message_prefix: &str, + max_size_bytes: usize, +) -> Result, AppError> { + let object_key = object_key.trim().trim_start_matches('/'); + if object_key.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "match3d-assets", + "message": format!("{message_prefix}:objectKey 不能为空"), + })), + ); + } + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let signed = oss_client + .sign_get_object_url(platform_oss::OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(300), + }) + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let response = reqwest::Client::new() + .get(signed.signed_url.as_str()) + .send() + .await + .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; + let status = response.status(); + if !status.is_success() { + return Err(match3d_bad_gateway(format!( + "{message_prefix}:HTTP {}", + status.as_u16() + ))); + } + let bytes = response + .bytes() + .await + .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; + if bytes.is_empty() || bytes.len() > max_size_bytes { + return Err(match3d_bad_gateway(format!( + "{message_prefix}:内容为空或超过大小上限" + ))); + } + Ok(bytes.to_vec()) +} + fn build_match3d_material_sheet_prompt( config: &Match3DConfigJson, item_names: &[String], @@ -2475,9 +3231,13 @@ async fn persist_match3d_generated_bytes( ); } + let oss_http_client = reqwest::Client::builder() + .timeout(Duration::from_millis(MATCH3D_OSS_PUT_TIMEOUT_MS)) + .build() + .map_err(|error| match3d_bad_gateway(format!("构造抓大鹅 OSS 上传客户端失败:{error}")))?; let put_result = oss_client .put_object( - &reqwest::Client::new(), + &oss_http_client, OssPutObjectRequest { prefix: LegacyAssetPrefix::Match3DAssets, path_segments: std::iter::once(session_id) @@ -2834,6 +3594,112 @@ mod tests { ); } + #[test] + fn match3d_generated_asset_resume_keeps_ready_models_first() { + let assets = normalize_match3d_generated_item_assets_for_resume(vec![ + Match3DGeneratedItemAsset { + item_id: "match3d-item-2".to_string(), + item_name: "苹果".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i2/image.png".to_string(), + ), + model_src: Some( + "/generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), + ), + model_object_key: Some( + "generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), + ), + model_file_name: Some("model.glb".to_string()), + task_uuid: Some("task-2".to_string()), + subscription_key: Some("sub-2".to_string()), + background_music: None, + click_sound: None, + status: "model_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i1/image.png".to_string(), + ), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + ]); + + assert_eq!(assets[0].item_id, "match3d-item-1"); + assert_eq!(assets[1].item_id, "match3d-item-2"); + assert!(is_match3d_generated_asset_model_ready(&assets[1])); + assert!(!is_match3d_generated_asset_model_ready(&assets[0])); + } + + #[test] + fn match3d_required_item_images_require_object_keys() { + let assets = vec![ + Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i1/image.png".to_string(), + ), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-2".to_string(), + item_name: "苹果".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i2/image.png".to_string(), + ), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-3".to_string(), + item_name: "香蕉".to_string(), + image_src: Some("/generated-match3d-assets/s/p/items/i3/image.png".to_string()), + image_object_key: None, + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + background_music: None, + click_sound: None, + status: "image_ready".to_string(), + error: None, + }, + ]; + + assert!(!has_match3d_required_item_images(&assets)); + } + #[test] fn match3d_model_download_prefers_glb_file() { let files = vec![ diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 0be38519..37797e7f 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -26,6 +26,7 @@ use platform_oss::{ }; use serde_json::{Map, Value, json}; use shared_contracts::{ + creation_audio::CreationAudioAsset, puzzle_agent::{ CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest, PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse, @@ -56,7 +57,7 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, + PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, @@ -228,6 +229,7 @@ pub async fn generate_puzzle_onboarding_work( level_name: level_name.clone(), picture_description: prompt_text.clone(), picture_reference: None, + background_music: None, candidates, selected_candidate_id: Some(selected.candidate_id.clone()), cover_image_src: Some(selected.image_src.clone()), @@ -2059,6 +2061,7 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, + background_music: level.background_music.map(map_puzzle_audio_asset_record_response), candidates: level .candidates .into_iter() @@ -2071,6 +2074,70 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft } } +fn map_puzzle_audio_asset_record_response(asset: PuzzleAudioAssetRecord) -> CreationAudioAsset { + CreationAudioAsset { + task_id: asset.task_id, + provider: asset.provider, + asset_object_id: asset.asset_object_id, + asset_kind: asset.asset_kind, + audio_src: asset.audio_src, + prompt: asset.prompt, + title: asset.title, + updated_at: asset.updated_at, + } +} + +fn map_puzzle_audio_asset_domain_record( + asset: module_puzzle::PuzzleAudioAsset, +) -> PuzzleAudioAssetRecord { + PuzzleAudioAssetRecord { + task_id: asset.task_id, + provider: asset.provider, + asset_object_id: asset.asset_object_id, + asset_kind: asset.asset_kind, + audio_src: asset.audio_src, + prompt: asset.prompt, + title: asset.title, + updated_at: asset.updated_at, + } +} + +fn puzzle_audio_asset_response_module_json(asset: &Option) -> Value { + asset + .as_ref() + .map(|asset| { + json!({ + "task_id": asset.task_id, + "provider": asset.provider, + "asset_object_id": asset.asset_object_id, + "asset_kind": asset.asset_kind, + "audio_src": asset.audio_src, + "prompt": asset.prompt, + "title": asset.title, + "updated_at": asset.updated_at, + }) + }) + .unwrap_or(Value::Null) +} + +fn puzzle_audio_asset_record_module_json(asset: &Option) -> Value { + asset + .as_ref() + .map(|asset| { + json!({ + "task_id": asset.task_id, + "provider": asset.provider, + "asset_object_id": asset.asset_object_id, + "asset_kind": asset.asset_kind, + "audio_src": asset.audio_src, + "prompt": asset.prompt, + "title": asset.title, + "updated_at": asset.updated_at, + }) + }) + .unwrap_or(Value::Null) +} + fn map_puzzle_creator_intent_response( intent: PuzzleCreatorIntentRecord, ) -> PuzzleCreatorIntentResponse { @@ -2600,6 +2667,7 @@ fn parse_puzzle_level_records_from_module_json( level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, + background_music: level.background_music.map(map_puzzle_audio_asset_domain_record), candidates: level .candidates .into_iter() @@ -2767,6 +2835,7 @@ fn serialize_puzzle_levels_response( "level_name": level.level_name, "picture_description": level.picture_description, "picture_reference": level.picture_reference, + "background_music": puzzle_audio_asset_response_module_json(&level.background_music), "candidates": level .candidates .iter() @@ -2815,6 +2884,7 @@ fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result