From 5f1128540e038002ab2149b4fbfdd1a1af68ae54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Fri, 22 May 2026 03:49:35 +0800 Subject: [PATCH] feat: refine wooden fish runtime generation --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 8 + CONTEXT.md | 4 + ...€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md | 96 +++-- ...„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md | 2 + ...å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md | 1 + ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 4 +- packages/shared/src/contracts/woodenFish.ts | 4 + scripts/dev.mjs | 92 ++++- scripts/dev.test.ts | 58 +++ .../api-server/src/openai_image_generation.rs | 93 ++++- .../crates/api-server/src/wooden_fish.rs | 331 ++++++++++++++---- .../shared-contracts/src/wooden_fish.rs | 22 ++ .../src/mapper/wooden_fish.rs | 3 + .../wooden_fish_draft_compile_input_type.rs | 1 + .../wooden_fish_draft_snapshot_type.rs | 1 + .../wooden_fish_gallery_view_row_type.rs | 4 + .../wooden_fish_work_profile_row_type.rs | 7 + .../wooden_fish_work_snapshot_type.rs | 1 + .../wooden_fish_work_update_input_type.rs | 1 + .../spacetime-client/src/wooden_fish.rs | 74 ++++ .../crates/spacetime-module/src/migration.rs | 8 + .../spacetime-module/src/wooden_fish.rs | 31 ++ .../src/wooden_fish/tables.rs | 2 + .../spacetime-module/src/wooden_fish/types.rs | 4 + .../WoodenFishResultView.tsx | 29 +- .../WoodenFishRuntimeShell.tsx | 14 + .../miniGameDraftGenerationProgress.test.ts | 3 +- .../miniGameDraftGenerationProgress.ts | 22 +- src/services/wooden-fish/woodenFishClient.ts | 2 + 30 files changed, 804 insertions(+), 126 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 3fa96f2a..303282aa 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-22 敲木鱼图片创作采用åŒå›¾ image2 链路 + +- 背景:敲木鱼自定义题æåªç”Ÿæˆä¸­å¤®æ•²å‡»ç‰©æ—¶ï¼Œè¿è¡Œæ€ç¼ºå°‘与新主题匹é…的竖å±èƒŒæ™¯ï¼›è‹¥ç›´æŽ¥è®©èƒŒæ™¯ prompt è‡ªç”±å‘æŒ¥ï¼Œåˆå®¹æ˜“把敲击物或木槌画进背景里。 +- 决策:敲木鱼 `compile-draft` / `regenerate-hit-object` 图片链路固定为两步 image2 edits。第一步调用 VectorEngine `/v1/images/edits` + `gpt-image-2`,以默认木鱼图作为结构和画风å‚考,用户上传å‚考图åªä½œä¸ºåŒæ¬¡è¯·æ±‚的新主题å‚考,结åˆç”¨æˆ·é¢˜æå…³é”®è¯æˆ–å‚è€ƒå›¾ä¸»é¢˜ç”Ÿæˆ `1:1` 逿˜Žåº•新敲击物并写回 `hitObjectAsset`;第二步以新敲击物图作为主题和画风å‚考,结åˆç”¨æˆ·åŽŸå§‹é¢˜æç”Ÿæˆ `9:16` 背景环境图并写回 `backgroundAsset`。两步 prompt 使用 PRD 中固定éšè—关键è¯ï¼Œä¸è¿½åŠ é¢å¤– negative prompt;背景图ä¸å¾—åŒ…å«æ•²å‡»ç‰©æœ¬ä½“或木槌互动物å“。 +- å½±å“范围:`api-server` 木鱼图片生æˆç¼–排ã€`wooden_fish_work_profile.background_asset_json`ã€shared contractsã€å‰ç«¯ç»“果页 / è¿è¡Œæ€èƒŒæ™¯å±•ç¤ºã€æ•²æœ¨é±¼ PRD 和平å°é“¾è·¯æ–‡æ¡£ã€‚ +- éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œ `cargo test -p api-server wooden_fish --manifest-path server-rs/Cargo.toml`ã€`cargo test -p spacetime-client wooden_fish --manifest-path server-rs/Cargo.toml`ã€`npm run spacetime:generate`ã€`npm run check:spacetime-schema`ã€`npm run typecheck`。 +- å…³è”æ–‡æ¡£ï¼š`docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## 2026-05-21 外部 API 失败必须 OTLP 上报并è½åº“ - 背景:图片生æˆç­‰å¤–部供应商调用失败时,仅返回 502/504 或普通日志无法支æŒåŽç»­æŒ‰ providerã€é˜¶æ®µå’Œé‡è¯•属性èšåˆæŽ’障。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index e1f5ff2d..f2a48a48 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -478,6 +478,14 @@ - 验è¯ï¼š`spacetime server list` 默认目标为 localï¼›é‡æ–°ç™»å½•åŽå‘布ä¸å†è¿”回 `401` / `403`ï¼›`npm run dev` å¯ä»¥å®Œæˆ SpacetimeDB publish å¹¶ç»§ç»­å¯åЍ `api-server`。 - å…³è”:`docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`ã€`scripts/dev.mjs`。 +## 本地 api-server å¯åŠ¨è®¢é˜… 401 先查 Web identity token 注入 + +- 现象:`npm run dev` å¯åŠ¨åˆ° api-server æ¢å¤è®¤è¯å¿«ç…§æ—¶ï¼Œæ—¥å¿—出现 `Failed to initiate WebSocket connection ... /v1/database//subscribe?compression=Brotli: HTTP error: 401 Unauthorized`。 +- 原因:SpacetimeDB SDK è®¢é˜…éœ€è¦ Web API identity token;本地 `.env.local` 常把 `GENARRATIVE_SPACETIME_TOKEN` 留空,åªé  CLI ç™»å½•æ€ publish æˆåŠŸå¹¶ä¸èƒ½è®© api-server çš„ WebSocket subscribe 获得æƒé™ã€‚ +- 处ç†ï¼š`scripts/dev.mjs` 在 SpacetimeDB 就绪åŽè°ƒç”¨ `/v1/identity` 创建当å‰è¿›ç¨‹ä¸“用 Web API identity tokenï¼Œå¹¶åªæ³¨å…¥æœ¬æ¬¡ `api-server` 环境;ä¸è¦æŠŠä¸´æ—¶ token 写进 `.env.local` æˆ–æ—¥å¿—ã€‚è‹¥ä»æŠ¥ 401,先确认是å¦ä½¿ç”¨äº†é¡¹ç›®è„šæœ¬å¯åŠ¨ã€æ—¥å¿—是å¦å‡ºçް `已创建本地 Web identity`ï¼Œä»¥åŠ `GENARRATIVE_SPACETIME_SERVER_URL` / æ•°æ®åº“åæ˜¯å¦æŒ‡å‘本次å¯åŠ¨çš„å®žä¾‹ã€‚ +- 验è¯ï¼š`npm run test -- scripts/dev.test.ts`ï¼›é‡æ–°è¿è¡Œ `npm run dev` åŽ api-server å¯åŠ¨æ—¥å¿—ä¸å†å‡ºçŽ°ä¸Šè¿° subscribe 401,`/healthz` 返回 200。 +- å…³è”:`scripts/dev.mjs`ã€`scripts/dev.test.ts`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 + ## 本地 SpacetimeDB è”è°ƒå¯æŒ‰é˜¶æ®µè·³è¿‡å®¿ä¸»æˆ–å‘布 - 现象:本地 `npm run dev` å›  `3101` å·²å ç”¨ã€é‡å¤å‘布 SpacetimeDB wasm ç¼–è¯‘å¤ªæ…¢ï¼Œæˆ–åªæƒ³æ£€æŸ¥ `spacetime-module` 语法而被完整è”调链路拖慢。 diff --git a/CONTEXT.md b/CONTEXT.md index c431cb69..160d1edd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -28,6 +28,10 @@ _Avoid_: é•¿æœŸåŠŸå¾·è´¦æœ¬ã€æŽ’è¡Œæ¦œçŽ©æ³•ã€å…¨å±€è´¦æˆ·ç´¯è®¡ 敲木鱼作å“中被玩家点击敲击的å•张物å“图案;默认模æ¿ä½¿ç”¨å†…ç½®é€æ˜Ž PNG `/wooden-fish/default-hit-object.png`ï¼Œç”¨æˆ·è‡ªå®šä¹‰å…³é”®è¯æˆ–上传图时å†ä½¿ç”¨ image2 ç”Ÿæˆæœ€ç»ˆèµ„产,上传图åªä½œä¸º image2 å‚考。 _Avoid_: 直接把上传图作为è¿è¡Œæ€ç´ æã€ç³»åˆ—ç´ æå›¾é›† +**敲木鱼背景环境图**: +敲木鱼作å“ä¸­çš„ç«–å± 9:16 背景资产;由åŽç«¯åœ¨æ•²å‡»ç‰©å›¾æ¡ˆç”ŸæˆåŽï¼Œä»¥æ–°æ•²å‡»ç‰©å›¾æ¡ˆä½œä¸ºä¸»é¢˜å’Œç”»é£Žå‚考,å†ç»“åˆç”¨æˆ·åŽŸå§‹é¢˜æå…³é”®è¯æˆ–å‚考图主题调用 image2 生æˆã€‚背景åªé€‚é…æ•²å‡»ç‰©ä¸»é¢˜å’Œç”»é£Žï¼Œä¸åŒ…嫿•²å‡»ç‰©æœ¬ä½“或木槌互动物å“。 +_Avoid_: 把背景当å°é¢å›¾ã€åœ¨èƒŒæ™¯é‡Œé‡å¤ç»˜åˆ¶æ•²å‡»ç‰©ã€è®©å‰ç«¯ä¸´æ—¶æ‹¼èƒŒæ™¯ + **敲击音效**: 敲木鱼作å“ä¸­æ¯æ¬¡æœ‰æ•ˆæ•²å‡»æ’­æ”¾çš„短音频资产,å¯ç”±æè¿°ç”Ÿæˆã€æ–‡ä»¶ä¸Šä¼ æˆ–麦克风录制产生,最终统一写回作å“的敲击音效资产槽ä½ã€‚ _Avoid_: 背景音ä¹ã€é•¿éŸ³é¢‘轨é“ã€è¿è¡Œæ€å®žæ—¶å½•音 diff --git a/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md b/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md index fa7c4535..a660755c 100644 --- a/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md +++ b/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md @@ -74,7 +74,14 @@ WF-* - 写回字段:`hitObjectAsset` - 是å¦å…许历å²å›¾ï¼šå…许 - 是å¦å…许 AI é‡ç»˜ï¼šå…许;上传图åªä½œä¸º image2 å‚考,最终è¿è¡Œæ€åªæ¶ˆè´¹ image2 生æˆå›¾ -- ç³»åˆ—ç´ ææ§½ä½ï¼šæ— ï¼›é¦–ç‰ˆåªæœ‰å•图敲击物,ä¸ç”Ÿæˆå›¾é›† + - `slotId=background` + - `slotType=background-image` + - `slotName=背景环境图` + - æç¤ºè¯æ¥æºï¼šç¬¬ä¸€æ­¥ç”Ÿæˆçš„æ•²å‡»ç‰©å›¾æ¡ˆä¸Žç”¨æˆ·åŽŸå§‹é¢˜æå…³é”®è¯ / å‚考图主题 + - 写回字段:`backgroundAsset` + - 是å¦å…许历å²å›¾ï¼šä¸å•独选择;由敲击物图案生æˆé“¾è·¯æ´¾ç”Ÿ + - 是å¦å…许 AI é‡ç»˜ï¼šå…è®¸ï¼›éšæ•²å‡»ç‰©å›¾æ¡ˆä¸€èµ·é‡ç”Ÿæˆ +- ç³»åˆ—ç´ ææ§½ä½ï¼šæ— ï¼›é¦–ç‰ˆåªæœ‰æ•²å‡»ç‰©å›¾æ¡ˆä¸ŽèƒŒæ™¯çŽ¯å¢ƒå›¾ä¸¤ä¸ªå•图资产,ä¸ç”Ÿæˆå›¾é›† - 音频资产槽ä½ï¼š - `slotId=hit-sound` - `slotType=hit-sound-audio` @@ -135,13 +142,39 @@ WF-* ## 6. 生æˆè§„则 -### 6.1 敲击物图案 +### 6.1 敲击物图案与背景环境图 -默认模æ¿åœ¨ç”¨æˆ·æœªè‡ªå®šä¹‰å…³é”®è¯ä¸”未上传å‚考图时,`compile-draft` ä½¿ç”¨å†…ç½®é€æ˜Ž PNG `/wooden-fish/default-hit-object.png` 写回 `hitObjectAsset`,`generationProvider="bundled-default"`。这张图æ¥è‡ª image2 对原始å‚考图的å¡é€šé£Žæ ¼åŒ–é‡ç»˜ï¼Œå›ºå®šä¸ºæ¨¡æ¿é»˜è®¤èµ„æºï¼Œé¿å…默认关键è¯åœ¨æ¯æ¬¡ç”Ÿæˆæ—¶æ”¹å˜é€ åž‹ã€‚ +默认模æ¿åœ¨ç”¨æˆ·æœªè‡ªå®šä¹‰å…³é”®è¯ä¸”未上传å‚考图时,`compile-draft` ä½¿ç”¨å†…ç½®é€æ˜Ž PNG `/wooden-fish/default-hit-object.png` 写回 `hitObjectAsset`,`generationProvider="bundled-default"`。这张图æ¥è‡ª image2 对原始å‚考图的å¡é€šé£Žæ ¼åŒ–é‡ç»˜ï¼Œå›ºå®šä¸ºæ¨¡æ¿é»˜è®¤èµ„æºï¼Œé¿å…默认关键è¯åœ¨æ¯æ¬¡ç”Ÿæˆæ—¶æ”¹å˜é€ åž‹ã€‚å³ä½¿ä½¿ç”¨å†…置默认敲击物,首版ä»éœ€è¦ç”Ÿæˆ `backgroundAsset`,背景环境图使用默认敲击物作为主题和画风å‚考。 -用户输入自定义关键è¯ã€ä¸Šä¼ å‚考图,或在结果页主动é‡ç”Ÿæˆæ•²å‡»ç‰©æ—¶ï¼Œ`compile-draft` 与 `regenerate-hit-object` å¿…é¡»ä¸ºæ•²å‡»ç‰©å›¾æ¡ˆç”Ÿæˆ image2 å•图资产,并由 `api-server` 注入写回 `hitObjectAsset`。å‰ç«¯ action 请求ä¸å¾—自带 `hitObjectAsset` 短路生æˆã€‚如果用户上传å‚考图,åŽç«¯åªèƒ½æŠŠè¯¥å›¾ä½œä¸º image2 å‚考图或编辑输入;è¿è¡Œæ€ä¸å¾—直接使用上传图。 +用户输入自定义关键è¯ã€ä¸Šä¼ å‚考图,或在结果页主动é‡ç”Ÿæˆæ•²å‡»ç‰©æ—¶ï¼Œ`compile-draft` 与 `regenerate-hit-object` å¿…é¡»å…ˆä¸ºæ•²å‡»ç‰©å›¾æ¡ˆç”Ÿæˆ image2 å•图资产,å†åŸºäºŽæ–°æ•²å‡»ç‰©å›¾æ¡ˆç”ŸæˆèƒŒæ™¯çŽ¯å¢ƒå›¾ï¼Œå¹¶ç”± `api-server` 注入写回 `hitObjectAsset` 与 `backgroundAsset`。å‰ç«¯ action 请求ä¸å¾—自带 `hitObjectAsset` 或 `backgroundAsset` 短路生æˆã€‚如果用户上传å‚考图,åŽç«¯åªèƒ½æŠŠè¯¥å›¾ä½œä¸º image2 å‚考图或主题å‚考;è¿è¡Œæ€ä¸å¾—直接使用上传图。 -è½åº“链路固定为:`api-server` 调用 VectorEngine `gpt-image-2-all` -> æœåŠ¡ç«¯ä¸Šä¼  OSS ç§æœ‰å¯¹è±¡ -> `confirm_asset_object` 登记资产对象 -> `bind_asset_object_to_entity` 绑定到 `entityKind='wooden_fish_work'`ã€`slot='hit_object'`ã€`assetKind='wooden_fish_hit_object'` -> 把 `legacyPublicPath` 写入 `hitObjectAsset.imageSrc`。ä¸å¾—åªæ‹¼ `/generated-wooden-fish-assets/...` å ä½è·¯å¾„ï¼›å‰ç«¯ä¼šå¯¹ generated legacy path èµ° `/api/assets/read-url` æ¢ç­¾ï¼ŒOSS 中没有真实对象时图片无法显示。 +æ•²å‡»ç‰©å›¾æ¡ˆç”Ÿæˆæµç¨‹å›ºå®šä¸ºï¼š + +1. 调用 VectorEngine `/v1/images/edits`,模型固定为 `gpt-image-2`ï¼› +2. multipart å‚考图固定包å«é»˜è®¤æœ¨é±¼å›¾ `/wooden-fish/default-hit-object.png`,作为基础结构和画风å‚考; +3. 若用户上传å‚考图,该图åªä½œä¸ºæ–°ä¸»é¢˜å‚考追加到åŒä¸€æ¬¡ image2 edits 请求,ä¸ç›´æŽ¥è¿›å…¥è¿è¡Œæ€ï¼› +4. 尺寸固定 `1:1`ï¼Œé€æ˜Žåº•ï¼› +5. æç¤ºè¯ä¸¥æ ¼ä½¿ç”¨ï¼š + +```text +ç”Ÿæˆæ•²æœ¨é±¼æ–°æ ·å¼ï¼Œè¦æ±‚结构,画风与å‚è€ƒå›¾ä¿æŒé«˜åº¦ä¸€è‡´ï¼Œæ–°æ ·å¼é¢œè‰²æ­é…使用新主题对应的颜色。 +新主题为:(用户æä¾›å‚考图或用户输入关键è¯ï¼‰ +``` + +èƒŒæ™¯çŽ¯å¢ƒå›¾ç”Ÿæˆæµç¨‹å›ºå®šä¸ºï¼š + +1. 调用 VectorEngine `/v1/images/edits`,模型固定为 `gpt-image-2`ï¼› +2. multipart å‚考图固定为第一步新生æˆçš„æ•²å‡»ç‰©å›¾æ¡ˆï¼›é»˜è®¤æœªç”Ÿæˆæ–°æ•²å‡»ç‰©æ—¶ä½¿ç”¨å†…置默认敲击物图案; +3. å°ºå¯¸å›ºå®šç«–å± `9:16`ï¼› +4. 背景环境图åªé€‚é…æ–°æ•²å‡»ç‰©ä¸»é¢˜å’Œç”»é£Žï¼ŒèƒŒæ™¯ä¸­ä¸å¾—åŒ…å«æ–°æ•²å‡»ç‰©æœ¬ä½“,也ä¸å¾—增加木槌互动物å“ï¼› +5. æç¤ºè¯ä¸¥æ ¼ä½¿ç”¨ï¼š + +```text +ç”Ÿæˆæ•²æœ¨é±¼èƒŒæ™¯ï¼Œè¦æ±‚主题,画风与å‚è€ƒå›¾ä¿æŒé«˜åº¦ä¸€è‡´ï¼ŒèƒŒæ™¯å…ƒç´ å’Œé¢œè‰²æ­é…与主题对应,木鱼预设在å±å¹•中央ä½ç½®ï¼Œæœ¨é±¼ä¸»ä½“å‘¨å›´å…ƒç´ ä¿æŒå¹²å‡€ï¼ŒèƒŒæ™¯æ°›å›´å›´ç»•外围设计,背景环境图中ä¸åŒ…嫿–°æœ¨é±¼ç‰©å“,背景氛围中ä¸å¢žåŠ æœ¨æ§Œäº’åŠ¨ç‰©å“。 +主题为:(用户æä¾›å‚考图或用户输入关键è¯ï¼‰ +``` + +è½åº“链路固定为:`api-server` 调用 VectorEngine `/v1/images/edits` -> æœåŠ¡ç«¯ä¸Šä¼  OSS ç§æœ‰å¯¹è±¡ -> `confirm_asset_object` 登记资产对象 -> `bind_asset_object_to_entity` 绑定到 `entityKind='wooden_fish_work'`。敲击物绑定 `slot='hit_object'`ã€`assetKind='wooden_fish_hit_object'`,背景绑定 `slot='background'`ã€`assetKind='wooden_fish_background'`。写回时把 `legacyPublicPath` 分别写入 `hitObjectAsset.imageSrc` 与 `backgroundAsset.imageSrc`。ä¸å¾—åªæ‹¼ `/generated-wooden-fish-assets/...` å ä½è·¯å¾„ï¼›å‰ç«¯ä¼šå¯¹ generated legacy path èµ° `/api/assets/read-url` æ¢ç­¾ï¼ŒOSS 中没有真实对象时图片无法显示。 é»˜è®¤å›¾æ¡ˆè¦æ±‚: @@ -166,7 +199,7 @@ WF-* ### 6.3 å°é¢ -首版å°é¢ä½¿ç”¨ `hitObjectAsset.imageSrc` 作为 `coverImageSrc`。ä¸å•独新增第三次图片生æˆã€‚ +首版å°é¢ä½¿ç”¨ `hitObjectAsset.imageSrc` 作为 `coverImageSrc`。背景环境图ä¸ä½œä¸ºå°é¢å›¾ï¼Œä¸å•独新增第三次图片生æˆã€‚ ## 7. å¥‘çº¦è‰æ¡ˆ @@ -183,9 +216,10 @@ WF-* 9. `hitSoundPrompt`ï¼› 10. `floatingWords[]`ï¼› 11. `hitObjectAsset`ï¼› -12. `hitSoundAsset`ï¼› -13. `coverImageSrc`ï¼› -14. `generationStatus`。 +12. `backgroundAsset`ï¼› +13. `hitSoundAsset`ï¼› +14. `coverImageSrc`ï¼› +15. `generationStatus`。 `WoodenFishImageAsset` 至少包å«ï¼š @@ -260,7 +294,7 @@ finish 新增表: 1. `wooden_fish_agent_session`ï¼› -2. `wooden_fish_work_profile`ï¼› +2. `wooden_fish_work_profile`,其中 `background_asset_json` ä¿å­˜èƒŒæ™¯çŽ¯å¢ƒå›¾èµ„äº§å¿«ç…§ï¼› 3. `wooden_fish_runtime_run`ï¼› 4. `wooden_fish_event`。 @@ -276,13 +310,14 @@ finish 结果页必须展示: 1. ä½œå“æ ‡é¢˜å’Œç®€ä»‹ï¼› -2. 敲击物图案; -3. 敲击音效试å¬ï¼› -4. ç¥ç¦è¯é…置; -5. 标签; -6. 试玩; -7. å‘布; -8. 返回编辑。 +2. ç«–å±èƒŒæ™¯çŽ¯å¢ƒå›¾é¢„è§ˆï¼› +3. 敲击物图案; +4. 敲击音效试å¬ï¼› +5. ç¥ç¦è¯é…置; +6. 标签; +7. 试玩; +8. å‘布; +9. 返回编辑。 结果页必须支æŒï¼š @@ -333,16 +368,17 @@ finish 1. 创作入å£èƒ½çœ‹åˆ° `敲木鱼` 模æ¿ï¼› 2. 工作å°å¯ä»¥å¡«å†™æ•²å‡»ç‰©æè¿°ã€ä¸Šä¼ å‚考图ã€é…置音效和ç¥ç¦è¯ï¼› -3. æäº¤åŽç”Ÿæˆ image2 敲击物图案; -4. 上传图ä¸ä¼šç›´æŽ¥è¿›å…¥è¿è¡Œæ€ï¼› -5. 用户上传或录制音效时跳过音效生æˆå¹¶æŒä¹…化该资产; -6. 结果页能看到图案ã€è¯•å¬éŸ³æ•ˆã€ç¼–辑ç¥ç¦è¯å¹¶è¯•玩; -7. è¿è¡Œæ€åŠŸèƒ½åŒºç‚¹å‡»ä¸è§¦å‘敲击; -8. éžåŠŸèƒ½åŒºç‚¹å‡»ä¼šè®¡æ•°ã€æ’­æ”¾éŸ³æ•ˆã€æ’­æ”¾æ•²å‡»åŠ¨ç”»å¹¶é£˜å­—ï¼› -9. 顶部计数器åªåœ¨è¯æ¡é¦–次出现时创建; -10. 连点ä¸ä¸¢è®¡æ•°ï¼› -11. `checkpoint` å’Œ `finish` åªä¿å­˜å•次 run 摘è¦ï¼› -12. 作å“å¯ä»¥å‘布ã€è¿›å…¥å…¬å¼€åˆ—表和公开详情; -13. `WF-*` 公开作å“å·èƒ½è¿›å…¥åˆ†äº«å’Œè¿è¡Œæ€ï¼› -14. `npm run check:encoding` 通过; -15. schema å˜æ›´åŽ `npm run check:spacetime-schema` 通过。 +3. æäº¤åŽæŒ‰é»˜è®¤æœ¨é±¼å‚è€ƒå›¾ç”Ÿæˆ image2 敲击物图案; +4. æäº¤åŽæŒ‰æ–°æ•²å‡»ç‰©å›¾æ¡ˆå‚è€ƒå›¾ç”Ÿæˆ 9:16 背景环境图; +5. 上传图ä¸ä¼šç›´æŽ¥è¿›å…¥è¿è¡Œæ€ï¼› +6. 用户上传或录制音效时跳过音效生æˆå¹¶æŒä¹…化该资产; +7. 结果页能看到背景ã€å›¾æ¡ˆã€è¯•å¬éŸ³æ•ˆã€ç¼–辑ç¥ç¦è¯å¹¶è¯•玩; +8. è¿è¡Œæ€åŠŸèƒ½åŒºç‚¹å‡»ä¸è§¦å‘敲击; +9. éžåŠŸèƒ½åŒºç‚¹å‡»ä¼šè®¡æ•°ã€æ’­æ”¾éŸ³æ•ˆã€æ’­æ”¾æ•²å‡»åŠ¨ç”»å¹¶é£˜å­—ï¼› +10. 顶部计数器åªåœ¨è¯æ¡é¦–次出现时创建; +11. 连点ä¸ä¸¢è®¡æ•°ï¼› +12. `checkpoint` å’Œ `finish` åªä¿å­˜å•次 run 摘è¦ï¼› +13. 作å“å¯ä»¥å‘布ã€è¿›å…¥å…¬å¼€åˆ—表和公开详情; +14. `WF-*` 公开作å“å·èƒ½è¿›å…¥åˆ†äº«å’Œè¿è¡Œæ€ï¼› +15. `npm run check:encoding` 通过; +16. schema å˜æ›´åŽ `npm run check:spacetime-schema` 通过。 diff --git a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md index 981e1e07..5319448a 100644 --- a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md +++ b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md @@ -160,6 +160,7 @@ npm run check:server-rs-ddd - Match3D ç‰©å“ sheet:VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`;图集 promptã€åˆ‡å›¾ã€é€æ˜ŽåŒ–和切片æŒä¹…化走 `generated_asset_sheets` 通用模å—,Match3D åªè¡¥é¢˜æ / 风格 / 五视角设定和字段映射。 - Match3D å°é¢å’Œ 9:16 纯背景:VectorEngine `/v1/images/generations`。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart å‚考图。该容器å‚考图是åŽç«¯ç”Ÿå›¾å议输入,必须通过 `include_bytes!` éš `api-server` 编译进二进制,é¿å… API å•独å‘布或è¿è¡Œç›®å½•缺少 `public/` 时生æˆå¤±è´¥ã€‚ +- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`ã€‚æ•²å‡»ç‰©æ”¯æŒ multipart 多å‚考图,第一张固定为åŽç«¯å†…嵌默认木鱼图,用户上传图åªä½œä¸ºæ–°ä¸»é¢˜å‚考;背景环境图åªä½¿ç”¨æ–°æ•²å‡»ç‰©å›¾ä½œä¸ºå‚考。 - Hyper3D / Rodin:åªä¿ç•™åŽç«¯å®‰å…¨ä»£ç†å’Œæ—§æ•°æ®å…¼å®¹ï¼›æ–° Match3D è‰ç¨¿å’Œæ‰¹é‡æ–°å¢žä¸å†ç”Ÿæˆ GLB。 - 音频:视觉å°è¯´ä¸“用音频路由ä¿ç•™ï¼›æ‹¼å›¾å’ŒæŠ“大鹅生æˆå…¥å£æš‚时关闭,通用 `/api/creation/audio/*` 对相关目标返回 `410 Gone`;敲木鱼 `hit_sound` 目标例外开放,å¤ç”¨ VectorEngine Vidu 音效生æˆã€OSS ç§æœ‰å¯¹è±¡ã€`asset_object` å’Œ entity binding 链路,目标字段固定为 `entityKind='wooden_fish_work'`ã€`slot='hit_sound'`ã€`assetKind='wooden_fish_hit_sound'`ã€`storagePrefix='wooden_fish_assets'`。 - OSSï¼šç§æœ‰ generated legacy path 进入æµè§ˆå™¨å‰å¿…须通过 `/api/assets/read-url` æ¢ç­¾ï¼›ä¸è¦è£¸è¯·æ±‚ `/generated-*`。 @@ -421,6 +422,7 @@ npm run check:server-rs-ddd - Rust 结构体:`WoodenFishWorkProfileRow` - æºç ï¼š`server-rs/crates/spacetime-module/src/wooden_fish/tables.rs` +- è¯´æ˜Žï¼šæ•²æœ¨é±¼ä½œå“ profile çœŸç›¸ï¼ŒåŒ…å«æ•²å‡»ç‰©å›¾æ¡ˆã€èƒŒæ™¯çŽ¯å¢ƒå›¾ã€æ•²å‡»éŸ³æ•ˆã€é£˜å­—é…ç½®ã€å‘布状æ€å’Œå…¬å¼€è®¡æ•°ï¼›`background_asset_json` 是åŽåŠ å…¥å­—æ®µï¼Œä¿å­˜ image2 生æˆçš„ 9:16 背景环境图资产快照,旧è¿ç§»æ•°æ®æŒ‰ `None` 兼容。 ### SpacetimeDB view:`wooden_fish_gallery_card_view` diff --git a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md index c7e144df..0f9df588 100644 --- a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md +++ b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md @@ -169,6 +169,7 @@ UI 相关修改è¦é‡ç‚¹éªŒè¯ï¼š 4. 身份问题先查 `spacetime login show`ã€`spacetime server list` 和目标库æƒé™ï¼Œä¸é€šè¿‡åˆ‡å›žæ—§ Node / PostgreSQL 绕过。 5. 旧库è¿ç§»æˆ– private 表数æ®ä¿ç•™èµ° `migration.rs` çš„ JSON 导入导出和分片导入æ€è·¯ã€‚ 6. Jenkins æ•°æ®åº“导入 / å¯¼å‡ºæµæ°´çº¿ä¼šå…ˆåŠ è½½ `scripts/jenkins-prepare-toolchain-env.sh`,显å¼è¡¥é½ Jenkins 用户的 Nodeã€Cargoã€SpacetimeDB 工具链目录;如果目标机器安装路径ä¸åŒï¼Œç”¨ `GENARRATIVE_JENKINS_TOOL_PATHS` ä¼ å…¥é¢å¤– `bin` 目录。 +7. 本地 `npm run dev` / `npm run dev:api-server` è‹¥æ²¡æœ‰æ˜¾å¼ `GENARRATIVE_SPACETIME_TOKEN`,会在 SpacetimeDB 就绪åŽè°ƒç”¨ `/v1/identity` 创建当å‰è¿›ç¨‹ä¸“用 Web API identity tokenï¼Œå¹¶åªæ³¨å…¥æœ¬æ¬¡ `api-server` 环境,ä¸å†™å›ž `.env.local`。å¯åŠ¨æ—¥å¿—åªæ‰“å° identity å‰ç¼€ï¼Œç¦æ­¢æ‰“å° token 明文;若ä»å‡ºçް `subscribe ... 401 Unauthorized`,先确认是å¦ç»•过了项目 dev 脚本或是å¦è¿žæŽ¥åˆ°éžæœ¬æ¬¡å¯åŠ¨çš„ SpacetimeDB server。 ## 生产è¿ç»´ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index 0ace52cb..c0982b00 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -99,10 +99,12 @@ 创作输入固定为: -1. `敲什么`:å•图资产槽ä½ã€‚默认模æ¿ä½¿ç”¨å†…ç½®é€æ˜Ž PNG `/wooden-fish/default-hit-object.png` 作为 `bundled-default` 敲击物资产,é¿å…默认关键è¯è¢«é‡æ–°è¯­ä¹‰åŒ–æ”¹å½¢ï¼›ç”¨æˆ·è¾“å…¥è‡ªå®šä¹‰å…³é”®è¯æˆ–上传å‚考图时,åŽç«¯å¿…须使用 image2 ç”Ÿæˆæœ€ç»ˆæ•²å‡»ç‰©å›¾æ¡ˆï¼Œä¸Šä¼ å›¾åªä½œä¸ºå‚考,ä¸ç›´æŽ¥è¿›å…¥è¿è¡Œæ€ã€‚自定义 `compile-draft` / `regenerate-hit-object` å¿…é¡»å®Œæˆ image2 -> OSS ç§æœ‰å¯¹è±¡ -> asset object 登记和绑定åŽï¼Œå†ç”± `api-server` 注入真实 `hitObjectAsset.imageSrc`,ä¸èƒ½åªå†™ `/generated-wooden-fish-assets/...` å ä½è·¯å¾„,也ä¸èƒ½æŽ¥å—å‰ç«¯è¯·æ±‚自带的 `hitObjectAsset` 短路生æˆã€‚ +1. `敲什么`:敲击物å•图资产槽ä½ã€‚默认模æ¿ä½¿ç”¨å†…ç½®é€æ˜Ž PNG `/wooden-fish/default-hit-object.png` 作为 `bundled-default` 敲击物资产,é¿å…默认关键è¯è¢«é‡æ–°è¯­ä¹‰åŒ–æ”¹å½¢ï¼›ç”¨æˆ·è¾“å…¥è‡ªå®šä¹‰å…³é”®è¯æˆ–上传å‚考图时,åŽç«¯å¿…须以默认木鱼图作为基础结构和画风å‚考,使用 image2 ç”Ÿæˆæœ€ç»ˆæ•²å‡»ç‰©å›¾æ¡ˆï¼Œä¸Šä¼ å›¾åªä½œä¸ºæ–°ä¸»é¢˜å‚考,ä¸ç›´æŽ¥è¿›å…¥è¿è¡Œæ€ã€‚自定义 `compile-draft` / `regenerate-hit-object` å¿…é¡»å®Œæˆ image2 -> OSS ç§æœ‰å¯¹è±¡ -> asset object 登记和绑定åŽï¼Œå†ç”± `api-server` 注入真实 `hitObjectAsset.imageSrc`,ä¸èƒ½åªå†™ `/generated-wooden-fish-assets/...` å ä½è·¯å¾„,也ä¸èƒ½æŽ¥å—å‰ç«¯è¯·æ±‚自带的 `hitObjectAsset` 短路生æˆã€‚ 2. `敲击音效`:音频资产槽ä½ï¼Œæ”¯æŒæè¿°ç”Ÿæˆã€ä¸Šä¼ å’Œéº¦å…‹é£Žå½•制,统一写回 `hitSoundAsset`。æè¿°ç”Ÿæˆå¤ç”¨é€šç”¨ `/api/creation/audio/sound-effect` çš„ VectorEngine Vidu 音效生æˆã€ä¸‹è½½ã€OSS ç§æœ‰å¯¹è±¡ã€asset object 登记和 entity binding 链路;木鱼目标固定为 `entityKind='wooden_fish_work'`ã€`slot='hit_sound'`ã€`assetKind='wooden_fish_hit_sound'`ã€`storagePrefix='wooden_fish_assets'`,ä¸å¾—å†è¿”回 `410 Gone`,也ä¸å¾—ç”± `spacetime-client` åˆæˆå‡éŸ³é¢‘路径。 3. `功德有什么`:最多 8 æ¡é£˜å­—,默认 `幸è¿ã€å¥åº·ã€è´¢å¯Œã€å§»ç¼˜ã€å¹¸ç¦ã€äº‹ä¸šã€æˆåŠŸã€åŠŸå¾·`;创作æ€åªä¿å­˜è¯æ¡å,è¿è¡Œæ€é£˜å­—展示时å†è¿½åŠ  `+1`。 +图片生æˆé“¾è·¯å›ºå®šä¸ºåŒå›¾ image2 æµç¨‹ï¼šç¬¬ä¸€æ­¥ç”¨é»˜è®¤æœ¨é±¼å›¾ä½œä¸ºç»“构和画风å‚考,按用户题æå…³é”®è¯æˆ–å‚è€ƒå›¾ä¸»é¢˜ç”Ÿæˆ `1:1` 逿˜Žåº•新敲击物;第二步用新敲击物作为主题和画风å‚è€ƒç”Ÿæˆ `9:16` 背景环境图,背景图åªé€‚é…主题和画风,ä¸èƒ½åŒ…嫿–°æ•²å‡»ç‰©æœ¬ä½“,也ä¸èƒ½å¢žåŠ æœ¨æ§Œäº’åŠ¨ç‰©å“。两个资产分别写回 `hitObjectAsset` 与 `backgroundAsset`,并绑定到 `wooden_fish_work` çš„ `hit_object` / `background` æ§½ä½ã€‚è¿è¡Œæ€å’Œç»“果页消费 `backgroundAsset` åšç«–å±èƒŒæ™¯ï¼Œä¸­å¤®å†å åŠ  `hitObjectAsset`。 + è¿è¡Œæ€è§„则真相以åŽç«¯ run 摘è¦ä¸ºå‡†ï¼Œå‰ç«¯åªåšç‚¹å‡»ä½Žå»¶è¿Ÿè¡¨çŽ°ã€æ•²å‡»åŠ¨ç”»ã€éŸ³é¢‘æ’­æ”¾å’Œé£˜å­—æ¸²æŸ“ã€‚æ¯æ¬¡éžåŠŸèƒ½åŒºç‚¹å‡»åœ¨å½“å‰ run 内累计 `totalTapCount` å’Œ `wordCounters`;计数ä¸è¿›å…¥è´¦å·é•¿æœŸè´¦æœ¬ï¼Œä¸åšæŽ’è¡Œæ¦œã€‚é¡¶éƒ¨è®¡æ•°å™¨ä»…åœ¨è¯æ¡é¦–次出现时创建,åŽç»­åŒè¯æ¡ç»§ç»­ç´¯åŠ ã€‚ å¹³å°é¦–页推èã€ç²¾é€‰ã€æœ€æ–°ã€å…¬å¼€è¯¦æƒ…ã€æœç´¢ã€å·²çŽ©ä½œå“和公开试玩统一按 `sourceType='wooden-fish'` 与 `WF-*` 公开作å“å·è¯†åˆ«æ•²æœ¨é±¼ä½œå“;公开列表应走 `wooden_fish_gallery_card_view` 订阅缓存,公开详情或è¿è¡Œæ€å¯åŠ¨æ—¶å¡ç‰‡æ‘˜è¦ä¸è¶³åˆ™è¡¥è¯»å®Œæ•´ work profile。 diff --git a/packages/shared/src/contracts/woodenFish.ts b/packages/shared/src/contracts/woodenFish.ts index e1026579..ae035f8f 100644 --- a/packages/shared/src/contracts/woodenFish.ts +++ b/packages/shared/src/contracts/woodenFish.ts @@ -55,6 +55,8 @@ export interface WoodenFishActionRequest { themeTags?: string[] | null; hitObjectPrompt?: string | null; hitObjectReferenceImageSrc?: string | null; + hitObjectAsset?: WoodenFishImageAsset | null; + backgroundAsset?: WoodenFishImageAsset | null; hitSoundPrompt?: string | null; hitSoundAsset?: WoodenFishAudioAsset | null; floatingWords?: string[] | null; @@ -77,6 +79,7 @@ export interface WoodenFishDraftResponse { hitSoundPrompt: string | null; floatingWords: string[]; hitObjectAsset: WoodenFishImageAsset | null; + backgroundAsset: WoodenFishImageAsset | null; hitSoundAsset: WoodenFishAudioAsset | null; coverImageSrc: string | null; generationStatus: WoodenFishGenerationStatus; @@ -123,6 +126,7 @@ export interface WoodenFishWorkProfileResponse { summary: WoodenFishWorkSummaryResponse; draft: WoodenFishDraftResponse; hitObjectAsset: WoodenFishImageAsset; + backgroundAsset: WoodenFishImageAsset | null; hitSoundAsset: WoodenFishAudioAsset; floatingWords: string[]; } diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 363998a1..ef4fe055 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -816,7 +816,28 @@ class DevRunner { console.log(`[dev:spacetime] è¿ç§»å¼•导密钥: ${this.options.migrationBootstrapSecret}`); } - startApiServer(service) { + async ensureApiServerSpacetimeToken() { + const existingToken = String(this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '').trim(); + if (existingToken && shouldTrustExistingSpacetimeToken(existingToken, this.state.spacetimeServer)) { + return; + } + + const identityUrl = buildUrl(this.state.spacetimeServer, '/v1/identity'); + if (!identityUrl) { + throw new Error(`无法构造 SpacetimeDB identity 地å€: ${this.state.spacetimeServer}`); + } + + const response = await fetchSpacetimeIdentity(identityUrl); + this.baseEnv.GENARRATIVE_SPACETIME_TOKEN = response.token; + this.state.spacetimeIdentity = response.identity; + console.log( + `[dev:spacetime] 已创建本地 Web identity: ${response.identity.slice(0, 12)}...`, + ); + } + + async startApiServer(service) { + await this.ensureApiServerSpacetimeToken(); + const mergedEnv = { ...this.baseEnv, GENARRATIVE_API_HOST: this.options.apiHost, @@ -1413,6 +1434,75 @@ async function isHttpReady(url, timeoutMs = 1000) { } } +async function fetchSpacetimeIdentity(url) { + let response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + throw new Error( + `SpacetimeDB identity 请求失败: ${url}; ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const text = await response.text(); + if (!response.ok) { + throw new Error(`SpacetimeDB identity HTTP ${response.status}: ${trimPreview(text)}`); + } + + let payload; + try { + payload = JSON.parse(text); + } catch (error) { + throw new Error( + `SpacetimeDB identity å“åº”ä¸æ˜¯åˆæ³• JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const identity = + payload.identity ?? payload.Identity ?? payload.identity_hex ?? payload.identityHex; + const token = payload.token ?? payload.Token; + if (typeof identity !== 'string' || typeof token !== 'string') { + throw new Error(`SpacetimeDB identity å“应缺少 identity/token: ${trimPreview(text)}`); + } + + return {identity, token}; +} + +function shouldTrustExistingSpacetimeToken(existingToken, serverUrl) { + const shellToken = String(process.env.GENARRATIVE_SPACETIME_TOKEN ?? '').trim(); + if (shellToken && shellToken === existingToken) { + return true; + } + + return !isLoopbackSpacetimeServer(serverUrl); +} + +function isLoopbackSpacetimeServer(serverUrl) { + try { + const url = new URL(serverUrl); + return ['127.0.0.1', 'localhost', '::1'].includes(url.hostname); + } catch { + return false; + } +} + +function trimPreview(text, maxLength = 300) { + const normalized = String(text ?? '').replace(/\s+/gu, ' ').trim(); + return normalized.length > maxLength + ? `${normalized.slice(0, maxLength)}...` + : normalized; +} + function runForeground(command, args, {cwd, env, label}) { return new Promise((resolveRun, rejectRun) => { const child = spawn(command, args, { diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 643ff209..06015c59 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -271,4 +271,62 @@ describe('dev scheduler spacetime refresh', () => { expect(runner.waitForSpacetime).not.toHaveBeenCalled(); expect(runner.publishSpacetimeModule).not.toHaveBeenCalled(); }); + + test('å¯åЍ api-server å‰ä¸ºç©º token 自动创建本地 Web identity', async () => { + const {explicitOptions, options} = parseArgs([], { + GENARRATIVE_SPACETIME_TOKEN: '', + }); + const runner = new DevRunner(options, {}, explicitOptions); + runner.state.spacetimeServer = 'http://127.0.0.1:3101'; + globalThis.fetch = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + identity: 'c200localidentity', + token: 'local-web-token', + }), + })) as unknown as typeof fetch; + + await runner.ensureApiServerSpacetimeToken(); + + expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('local-web-token'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:3101/v1/identity', + expect.objectContaining({ + method: 'POST', + }), + ); + }); + + test('本地 SpacetimeDB ä¸ä¿¡ä»» env 文件中的陈旧 token', async () => { + const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN; + delete process.env.GENARRATIVE_SPACETIME_TOKEN; + try { + const {explicitOptions, options} = parseArgs([], { + GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token', + }); + const runner = new DevRunner(options, {GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token'}, explicitOptions); + runner.state.spacetimeServer = 'http://127.0.0.1:3101'; + globalThis.fetch = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + identity: 'c200freshidentity', + token: 'fresh-web-token', + }), + })) as unknown as typeof fetch; + + await runner.ensureApiServerSpacetimeToken(); + + expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('fresh-web-token'); + } finally { + if (originalToken === undefined) { + delete process.env.GENARRATIVE_SPACETIME_TOKEN; + } else { + process.env.GENARRATIVE_SPACETIME_TOKEN = originalToken; + } + } + }); }); diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index ebf6e8a8..6689365a 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -380,17 +380,41 @@ pub(crate) async fn create_openai_image_edit( reference_image: &OpenAiReferenceImage, failure_context: &str, ) -> Result { + create_openai_image_edit_with_references( + http_client, + settings, + prompt, + negative_prompt, + size, + std::slice::from_ref(reference_image), + failure_context, + ) + .await +} + +pub(crate) async fn create_openai_image_edit_with_references( + http_client: &reqwest::Client, + settings: &OpenAiImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + reference_images: &[OpenAiReferenceImage], + failure_context: &str, +) -> Result { + if reference_images.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("{failure_context}:缺少å‚考图"), + })), + ); + } + let task_id = format!("vector-engine-edit-{}", current_utc_micros()); let request_url = vector_engine_images_edit_url(settings); let normalized_size = normalize_image_size(size); - let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) - .file_name(reference_image.file_name.clone()) - .mime_str(reference_image.mime_type.as_str()) - .map_err(|error| { - map_openai_image_request_error(format!("{failure_context}:构造å‚考图失败:{error}")) - })?; - let form = reqwest::multipart::Form::new() - .part("image", image_part) + + let mut form = reqwest::multipart::Form::new() .text("model", GPT_IMAGE_2_MODEL.to_string()) .text( "prompt", @@ -398,7 +422,20 @@ pub(crate) async fn create_openai_image_edit( ) .text("n", "1") .text("size", normalized_size.clone()); + for reference_image in reference_images { + let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) + .file_name(reference_image.file_name.clone()) + .mime_str(reference_image.mime_type.as_str()) + .map_err(|error| { + map_openai_image_request_error(format!( + "{failure_context}:构造å‚考图失败:{error}" + )) + })?; + form = form.part("image", image_part); + } + let started_at = std::time::Instant::now(); + let reference_image_count = reference_images.len(); let response = match http_client .post(request_url.as_str()) .header( @@ -432,7 +469,7 @@ pub(crate) async fn create_openai_image_edit( None, Some(latency_ms), Some(prompt.chars().count()), - Some(1), + Some(reference_image_count), ), ) .await; @@ -450,7 +487,7 @@ pub(crate) async fn create_openai_image_edit( status = response_status.as_u16(), prompt_chars = prompt.chars().count(), size = %normalized_size, - reference_image_count = 1usize, + reference_image_count, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, "VectorEngine 图片编辑 HTTP 返回" @@ -478,7 +515,7 @@ pub(crate) async fn create_openai_image_edit( None, Some(latency_ms), Some(prompt.chars().count()), - Some(1), + Some(reference_image_count), ), ) .await; @@ -505,7 +542,7 @@ pub(crate) async fn create_openai_image_edit( Some(truncate_raw(response_text.as_str())), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), - Some(1), + Some(reference_image_count), ), ) .await; @@ -534,7 +571,7 @@ pub(crate) async fn create_openai_image_edit( Some(truncate_raw(response_text.as_str())), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), - Some(1), + Some(reference_image_count), ), ) .await; @@ -565,7 +602,7 @@ pub(crate) async fn create_openai_image_edit( None, Some(download_started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), - Some(1), + Some(reference_image_count), ), ) .await; @@ -597,7 +634,7 @@ pub(crate) async fn create_openai_image_edit( Some(truncate_raw(response_text.as_str())), Some(started_at.elapsed().as_millis() as u64), Some(prompt.chars().count()), - Some(1), + Some(reference_image_count), ), ) .await; @@ -1100,6 +1137,32 @@ mod tests { ); } + #[tokio::test] + async fn vector_engine_multi_reference_edit_rejects_empty_references() { + let settings = OpenAiImageSettings { + base_url: "https://vector.example".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000_000, + external_api_audit_state: None, + }; + let http_client = reqwest::Client::new(); + + let result = create_openai_image_edit_with_references( + &http_client, + &settings, + "æç¤ºè¯", + None, + "1:1", + &[], + "测试图片编辑失败", + ) + .await; + + let error = result.expect_err("empty references should be rejected locally"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("缺少å‚考图")); + } + #[test] fn b64_json_response_decodes_png_image() { let images = images_from_base64( diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index b0072a5b..43ffe1f3 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -28,14 +28,15 @@ use spacetime_client::SpacetimeClientError; use crate::generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput, - normalize_generated_image_asset_mime, + decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation, + DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client, + create_openai_image_edit, create_openai_image_edit_with_references, require_openai_image_settings, }, platform_errors::map_oss_error, @@ -58,9 +59,15 @@ const DEFAULT_HIT_OBJECT_IMAGE_SRC: &str = "/wooden-fish/default-hit-object.png" const WOODEN_FISH_ENTITY_KIND: &str = "wooden_fish_work"; const WOODEN_FISH_HIT_OBJECT_SLOT: &str = "hit_object"; const WOODEN_FISH_HIT_OBJECT_ASSET_KIND: &str = "wooden_fish_hit_object"; +const WOODEN_FISH_BACKGROUND_SLOT: &str = "background"; +const WOODEN_FISH_BACKGROUND_ASSET_KIND: &str = "wooden_fish_background"; const WOODEN_FISH_HIT_SOUND_SLOT: &str = "hit_sound"; const WOODEN_FISH_HIT_SOUND_ASSET_KIND: &str = "wooden_fish_hit_sound"; const WOODEN_FISH_HIT_SOUND_DURATION_SECONDS: u8 = 3; +const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../public/wooden-fish/default-hit-object.png" +)); pub async fn create_wooden_fish_session( State(state): State, @@ -372,6 +379,7 @@ fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> Wooden .or_else(|| Some(DEFAULT_HIT_SOUND_PROMPT.to_string())), floating_words: normalize_floating_words(payload.floating_words.clone()), hit_object_asset: None, + background_asset: None, hit_sound_asset: payload.hit_sound_asset.clone(), cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, @@ -410,7 +418,7 @@ async fn maybe_generate_hit_object_asset( ) { return Ok(()); } - if payload.hit_object_asset.is_some() { + if payload.hit_object_asset.is_some() && payload.background_asset.is_some() { return Ok(()); } @@ -424,32 +432,21 @@ async fn maybe_generate_hit_object_asset( .map(|value| clean_string(value, DEFAULT_HIT_OBJECT_PROMPT)) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| DEFAULT_HIT_OBJECT_PROMPT.to_string()); - let reference_images = payload - .hit_object_reference_image_src - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| vec![value.to_string()]) - .unwrap_or_default(); - if reference_images.is_empty() && is_default_hit_object_prompt(prompt.as_str()) { - payload.hit_object_asset = Some(default_wooden_fish_hit_object_asset()); - return Ok(()); - } - - let asset = generate_wooden_fish_hit_object_asset( + let generated = generate_wooden_fish_image_assets( state, owner_user_id, session_id, profile_id.as_str(), prompt.as_str(), - reference_images.as_slice(), + payload.hit_object_reference_image_src.as_deref(), ) .await .map_err(|error| { wooden_fish_error_response(request_context, WOODEN_FISH_CREATION_PROVIDER, error) })?; - payload.hit_object_asset = Some(asset); + payload.hit_object_asset = Some(generated.hit_object_asset); + payload.background_asset = Some(generated.background_asset); Ok(()) } @@ -469,8 +466,7 @@ fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset { fn is_default_hit_object_prompt(prompt: &str) -> bool { let normalized = normalize_hit_object_prompt_for_default_match(prompt); normalized.is_empty() - || normalized - == normalize_hit_object_prompt_for_default_match(DEFAULT_HIT_OBJECT_PROMPT) + || normalized == normalize_hit_object_prompt_for_default_match(DEFAULT_HIT_OBJECT_PROMPT) || normalized == normalize_hit_object_prompt_for_default_match("å¡é€šæœ¨é±¼ï¼Œåœ†æ¶¦å¯çˆ±ï¼Œé€æ˜ŽèƒŒæ™¯") || normalized @@ -655,64 +651,229 @@ fn map_generated_creation_audio_to_wooden_fish_asset( }) } -async fn generate_wooden_fish_hit_object_asset( +struct WoodenFishGeneratedImageAssets { + hit_object_asset: WoodenFishImageAsset, + background_asset: WoodenFishImageAsset, +} + +async fn generate_wooden_fish_image_assets( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, prompt: &str, - reference_images: &[String], -) -> Result { + hit_object_reference_image_src: Option<&str>, +) -> Result { let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; - let final_prompt = build_wooden_fish_hit_object_prompt(prompt); - let generated = create_openai_image_generation( + let clean_reference_image_src = hit_object_reference_image_src + .map(str::trim) + .filter(|value| !value.is_empty()); + let theme = resolve_wooden_fish_generation_theme(prompt, clean_reference_image_src); + let default_reference_image = default_wooden_fish_reference_image()?; + let theme_reference_image = + resolve_wooden_fish_theme_reference_image(clean_reference_image_src)?; + + let (hit_object_asset, background_reference_image) = + if should_generate_wooden_fish_hit_object(prompt, clean_reference_image_src) { + let hit_object_prompt = build_wooden_fish_hit_object_prompt(theme.as_str()); + let mut reference_images = vec![default_reference_image.clone()]; + if let Some(reference_image) = theme_reference_image { + reference_images.push(reference_image); + } + let generated = create_openai_image_edit_with_references( + &http_client, + &settings, + hit_object_prompt.as_str(), + None, + "1:1", + reference_images.as_slice(), + "ç”Ÿæˆæ•²æœ¨é±¼æ•²å‡»ç‰©å›¾æ¡ˆå¤±è´¥", + ) + .await?; + let task_id = generated.task_id.clone(); + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "ç”Ÿæˆæ•²æœ¨é±¼æ•²å‡»ç‰©å›¾æ¡ˆå¤±è´¥ï¼šä¸Šæ¸¸æœªè¿”回图片", + })) + })?; + let background_reference_image = + downloaded_wooden_fish_reference_image(&image, "wooden-fish-generated-hit-object"); + let hit_object_asset = persist_wooden_fish_image_asset( + state, + owner_user_id, + session_id, + profile_id, + task_id.as_str(), + hit_object_prompt.as_str(), + image, + current_utc_micros(), + WoodenFishImageSlotPersistSpec { + slot: WOODEN_FISH_HIT_OBJECT_SLOT, + asset_kind: WOODEN_FISH_HIT_OBJECT_ASSET_KIND, + asset_id_part: "hit-object", + width: 1024, + height: 1024, + }, + ) + .await?; + (hit_object_asset, background_reference_image) + } else { + ( + default_wooden_fish_hit_object_asset(), + default_reference_image, + ) + }; + + let background_prompt = build_wooden_fish_background_prompt(theme.as_str()); + let background_generated = create_openai_image_edit( &http_client, &settings, - final_prompt.as_str(), - Some(build_wooden_fish_hit_object_negative_prompt().as_str()), - "1024x1024", - 1, - reference_images, - "ç”Ÿæˆæ•²æœ¨é±¼æ•²å‡»ç‰©å›¾æ¡ˆå¤±è´¥", + background_prompt.as_str(), + None, + "9:16", + &background_reference_image, + "ç”Ÿæˆæ•²æœ¨é±¼èƒŒæ™¯çŽ¯å¢ƒå›¾å¤±è´¥", ) .await?; - let task_id = generated.task_id.clone(); - let image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "ç”Ÿæˆæ•²æœ¨é±¼æ•²å‡»ç‰©å›¾æ¡ˆå¤±è´¥ï¼šä¸Šæ¸¸æœªè¿”回图片", - })) - })?; - let generated_at_micros = current_utc_micros(); - let persisted = persist_wooden_fish_hit_object_asset( + let background_task_id = background_generated.task_id.clone(); + let background_image = background_generated + .images + .into_iter() + .next() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "ç”Ÿæˆæ•²æœ¨é±¼èƒŒæ™¯çŽ¯å¢ƒå›¾å¤±è´¥ï¼šä¸Šæ¸¸æœªè¿”å›žå›¾ç‰‡", + })) + })?; + let background_asset = persist_wooden_fish_image_asset( state, owner_user_id, session_id, profile_id, - task_id.as_str(), - &final_prompt, - image, - generated_at_micros, + background_task_id.as_str(), + background_prompt.as_str(), + background_image, + current_utc_micros(), + WoodenFishImageSlotPersistSpec { + slot: WOODEN_FISH_BACKGROUND_SLOT, + asset_kind: WOODEN_FISH_BACKGROUND_ASSET_KIND, + asset_id_part: "background", + width: 1024, + height: 1536, + }, ) .await?; - Ok(persisted) + Ok(WoodenFishGeneratedImageAssets { + hit_object_asset, + background_asset, + }) } fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String { format!( - "请使用 gpt-image-2 生æˆä¸€ä¸ªé€‚åˆç‚¹å‡»æ•²å‡»çŽ©æ³•çš„å•个物å“图案:{}。画é¢è¦æ±‚:å•个主体,å¡é€šæ’ç”»é£Žæ ¼ï¼Œé€æ˜Žæˆ–纯净浅色背景,居中构图,圆润å¯çˆ±ï¼Œè¾¹ç¼˜æ¸…晰,适åˆç§»åŠ¨ç«¯å±å¹•中央展示和点击动画缩放。ä¸è¦åŒ…嫿–‡å­—ã€æŒ‰é’®ã€UIã€è¾¹æ¡†ã€æ°´å°ã€å“牌标识或人物手部。", + "ç”Ÿæˆæ•²æœ¨é±¼æ–°æ ·å¼ï¼Œè¦æ±‚结构,画风与å‚è€ƒå›¾ä¿æŒé«˜åº¦ä¸€è‡´ï¼Œæ–°æ ·å¼é¢œè‰²æ­é…使用新主题对应的颜色。\n新主题为:{}", clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) ) } -fn build_wooden_fish_hit_object_negative_prompt() -> String { - "ä¸è¦ç”Ÿæˆæ–‡å­—ã€Logoã€æ°´å°ã€æŒ‰é’®ã€ç•Œé¢æˆªå›¾ã€å¤æ‚背景ã€å¤šä¸ªä¸»ä½“ã€çœŸå®žæ‘„å½±è´¨æ„Ÿã€ææ€–æˆ–è¡€è…¥å…ƒç´ ã€‚" - .to_string() +fn build_wooden_fish_background_prompt(prompt: &str) -> String { + format!( + "ç”Ÿæˆæ•²æœ¨é±¼èƒŒæ™¯ï¼Œè¦æ±‚主题,画风与å‚è€ƒå›¾ä¿æŒé«˜åº¦ä¸€è‡´ï¼ŒèƒŒæ™¯å…ƒç´ å’Œé¢œè‰²æ­é…与主题对应,木鱼预设在å±å¹•中央ä½ç½®ï¼Œæœ¨é±¼ä¸»ä½“å‘¨å›´å…ƒç´ ä¿æŒå¹²å‡€ï¼ŒèƒŒæ™¯æ°›å›´å›´ç»•外围设计,背景环境图中ä¸åŒ…嫿–°æœ¨é±¼ç‰©å“,背景氛围中ä¸å¢žåŠ æœ¨æ§Œäº’åŠ¨ç‰©å“。\n主题为:{}", + clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) + ) } -async fn persist_wooden_fish_hit_object_asset( +fn should_generate_wooden_fish_hit_object( + prompt: &str, + hit_object_reference_image_src: Option<&str>, +) -> bool { + hit_object_reference_image_src.is_some() || !is_default_hit_object_prompt(prompt) +} + +fn resolve_wooden_fish_generation_theme( + prompt: &str, + hit_object_reference_image_src: Option<&str>, +) -> String { + let prompt = clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT); + if !is_default_hit_object_prompt(prompt.as_str()) { + return prompt; + } + if hit_object_reference_image_src.is_some() { + return "用户æä¾›å‚考图".to_string(); + } + prompt +} + +fn default_wooden_fish_reference_image() -> Result { + let bytes = DEFAULT_HIT_OBJECT_REFERENCE_BYTES.to_vec(); + if bytes.is_empty() { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": WOODEN_FISH_CREATION_PROVIDER, + "message": "敲木鱼默认å‚考图为空", + })), + ); + } + Ok(OpenAiReferenceImage { + bytes, + mime_type: "image/png".to_string(), + file_name: "wooden-fish-default-hit-object-reference.png".to_string(), + }) +} + +fn resolve_wooden_fish_theme_reference_image( + source: Option<&str>, +) -> Result, AppError> { + let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if !source.to_ascii_lowercase().starts_with("data:image/") { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": WOODEN_FISH_CREATION_PROVIDER, + "field": "hitObjectReferenceImageSrc", + "message": "敲木鱼å‚考图必须是 base64 图片 Data URL。", + })), + ); + } + let decoded = decode_generated_image_asset_data_url(source).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": WOODEN_FISH_CREATION_PROVIDER, + "field": "hitObjectReferenceImageSrc", + "message": "敲木鱼å‚考图必须是 base64 图片 Data URL。", + })) + })?; + Ok(Some(OpenAiReferenceImage { + file_name: format!("wooden-fish-theme-reference.{}", decoded.format.extension), + mime_type: decoded.format.mime_type, + bytes: decoded.bytes, + })) +} + +fn downloaded_wooden_fish_reference_image( + image: &DownloadedOpenAiImage, + file_name_stem: &str, +) -> OpenAiReferenceImage { + OpenAiReferenceImage { + bytes: image.bytes.clone(), + mime_type: image.mime_type.clone(), + file_name: format!("{file_name_stem}.{}", image.extension), + } +} + +struct WoodenFishImageSlotPersistSpec { + slot: &'static str, + asset_kind: &'static str, + asset_id_part: &'static str, + width: u32, + height: u32, +} + +async fn persist_wooden_fish_image_asset( state: &AppState, owner_user_id: &str, session_id: &str, @@ -721,6 +882,7 @@ async fn persist_wooden_fish_hit_object_asset( prompt: &str, image: DownloadedOpenAiImage, generated_at_micros: i64, + spec: WoodenFishImageSlotPersistSpec, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ @@ -735,7 +897,7 @@ async fn persist_wooden_fish_hit_object_asset( path_segments: vec![ sanitize_wooden_fish_asset_segment(session_id, "session"), sanitize_wooden_fish_asset_segment(profile_id, "profile"), - WOODEN_FISH_HIT_OBJECT_SLOT.to_string(), + spec.slot.to_string(), format!("asset-{generated_at_micros}"), ], file_stem: "image".to_string(), @@ -745,11 +907,11 @@ async fn persist_wooden_fish_hit_object_asset( }, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { - asset_kind: Some(WOODEN_FISH_HIT_OBJECT_ASSET_KIND.to_string()), + asset_kind: Some(spec.asset_kind.to_string()), owner_user_id: Some(owner_user_id.to_string()), entity_kind: Some(WOODEN_FISH_ENTITY_KIND.to_string()), entity_id: Some(profile_id.to_string()), - slot: Some(WOODEN_FISH_HIT_OBJECT_SLOT.to_string()), + slot: Some(spec.slot.to_string()), provider: Some("image2".to_string()), task_id: Some(task_id.to_string()), }, @@ -784,7 +946,7 @@ async fn persist_wooden_fish_hit_object_asset( head.content_type.or(Some(persisted_mime_type)), head.content_length, head.etag, - WOODEN_FISH_HIT_OBJECT_ASSET_KIND.to_string(), + spec.asset_kind.to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), Some(profile_id.to_string()), @@ -808,8 +970,8 @@ async fn persist_wooden_fish_hit_object_asset( asset_object.asset_object_id.clone(), WOODEN_FISH_ENTITY_KIND.to_string(), profile_id.to_string(), - WOODEN_FISH_HIT_OBJECT_SLOT.to_string(), - WOODEN_FISH_HIT_OBJECT_ASSET_KIND.to_string(), + spec.slot.to_string(), + spec.asset_kind.to_string(), Some(owner_user_id.to_string()), Some(profile_id.to_string()), generated_at_micros, @@ -823,20 +985,21 @@ async fn persist_wooden_fish_hit_object_asset( owner_user_id, session_id, profile_id, + slot = spec.slot, error = %error, "敲木鱼图片资产绑定失败,历å²ç´ æç´¢å¼•å¯èƒ½ç¼ºå°‘绑定记录" ); } Ok(WoodenFishImageAsset { - asset_id: format!("{profile_id}-hit-object-{generated_at_micros}"), + asset_id: format!("{profile_id}-{}-{generated_at_micros}", spec.asset_id_part), image_src: put_result.legacy_public_path, image_object_key: head.object_key, asset_object_id: asset_object.asset_object_id, generation_provider: "image2".to_string(), prompt: prompt.to_string(), - width: 1024, - height: 1024, + width: spec.width, + height: spec.height, }) } @@ -1027,15 +1190,51 @@ fn current_utc_micros() -> i64 { #[cfg(test)] mod tests { use super::*; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; #[test] - fn wooden_fish_hit_object_prompt_keeps_user_object_and_image2_constraints() { + fn wooden_fish_hit_object_prompt_uses_hidden_image2_flow() { let prompt = build_wooden_fish_hit_object_prompt("èµ›åšèŽ²èŠ±æœ¨é±¼"); - assert!(prompt.contains("èµ›åšèŽ²èŠ±æœ¨é±¼")); - assert!(prompt.contains("gpt-image-2")); - assert!(prompt.contains("å•个主体")); - assert!(prompt.contains("ä¸è¦åŒ…嫿–‡å­—")); + assert_eq!( + prompt, + "ç”Ÿæˆæ•²æœ¨é±¼æ–°æ ·å¼ï¼Œè¦æ±‚结构,画风与å‚è€ƒå›¾ä¿æŒé«˜åº¦ä¸€è‡´ï¼Œæ–°æ ·å¼é¢œè‰²æ­é…使用新主题对应的颜色。\n新主题为:赛åšèŽ²èŠ±æœ¨é±¼" + ); + } + + #[test] + fn wooden_fish_background_prompt_uses_hidden_image2_flow() { + let prompt = build_wooden_fish_background_prompt("èµ›åšèŽ²èŠ±æœ¨é±¼"); + + assert_eq!( + prompt, + "ç”Ÿæˆæ•²æœ¨é±¼èƒŒæ™¯ï¼Œè¦æ±‚主题,画风与å‚è€ƒå›¾ä¿æŒé«˜åº¦ä¸€è‡´ï¼ŒèƒŒæ™¯å…ƒç´ å’Œé¢œè‰²æ­é…与主题对应,木鱼预设在å±å¹•中央ä½ç½®ï¼Œæœ¨é±¼ä¸»ä½“å‘¨å›´å…ƒç´ ä¿æŒå¹²å‡€ï¼ŒèƒŒæ™¯æ°›å›´å›´ç»•外围设计,背景环境图中ä¸åŒ…嫿–°æœ¨é±¼ç‰©å“,背景氛围中ä¸å¢žåŠ æœ¨æ§Œäº’åŠ¨ç‰©å“。\n主题为:赛åšèŽ²èŠ±æœ¨é±¼" + ); + } + + #[test] + fn wooden_fish_theme_reference_image_decodes_data_url_for_image2() { + let source = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nreference") + ); + + let image = resolve_wooden_fish_theme_reference_image(Some(source.as_str())) + .expect("data url should parse") + .expect("reference image should exist"); + + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.file_name, "wooden-fish-theme-reference.png"); + assert!(image.bytes.starts_with(b"\x89PNG\r\n\x1A\n")); + } + + #[test] + fn wooden_fish_theme_reference_image_rejects_non_data_url() { + let error = resolve_wooden_fish_theme_reference_image(Some("/generated/example.png")) + .expect_err("legacy path should not be accepted as direct image2 reference"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("Data URL")); } #[test] @@ -1053,7 +1252,9 @@ mod tests { fn wooden_fish_default_prompt_matches_legacy_defaults() { assert!(is_default_hit_object_prompt(DEFAULT_HIT_OBJECT_PROMPT)); assert!(is_default_hit_object_prompt("å¡é€šæœ¨é±¼ï¼Œåœ†æ¶¦å¯çˆ±ï¼Œé€æ˜ŽèƒŒæ™¯")); - assert!(is_default_hit_object_prompt("å¡é€šæœ¨é±¼ï¼Œé€æ˜ŽèƒŒæ™¯ï¼Œå±…中,圆润å¯çˆ±")); + assert!(is_default_hit_object_prompt( + "å¡é€šæœ¨é±¼ï¼Œé€æ˜ŽèƒŒæ™¯ï¼Œå±…中,圆润å¯çˆ±" + )); assert!(is_default_hit_object_prompt("å¡é€šæœ¨é±¼")); assert!(!is_default_hit_object_prompt("èµ›åšèŽ²èŠ±æœ¨é±¼")); } diff --git a/server-rs/crates/shared-contracts/src/wooden_fish.rs b/server-rs/crates/shared-contracts/src/wooden_fish.rs index 105d11b1..18cc81d5 100644 --- a/server-rs/crates/shared-contracts/src/wooden_fish.rs +++ b/server-rs/crates/shared-contracts/src/wooden_fish.rs @@ -90,6 +90,9 @@ pub struct WoodenFishActionRequest { #[serde(default, skip_deserializing)] pub hit_object_asset: Option, #[serde(default)] + #[serde(skip_deserializing)] + pub background_asset: Option, + #[serde(default)] pub hit_sound_prompt: Option, #[serde(default)] pub hit_sound_asset: Option, @@ -123,6 +126,8 @@ pub struct WoodenFishDraftResponse { #[serde(default)] pub hit_object_asset: Option, #[serde(default)] + pub background_asset: Option, + #[serde(default)] pub hit_sound_asset: Option, #[serde(default)] pub cover_image_src: Option, @@ -185,6 +190,8 @@ pub struct WoodenFishWorkProfileResponse { pub summary: WoodenFishWorkSummaryResponse, pub draft: WoodenFishDraftResponse, pub hit_object_asset: WoodenFishImageAsset, + #[serde(default)] + pub background_asset: Option, pub hit_sound_asset: WoodenFishAudioAsset, pub floating_words: Vec, } @@ -365,6 +372,18 @@ mod tests { width: 1024, height: 1024, }), + background_asset: Some(WoodenFishImageAsset { + asset_id: "background-1".to_string(), + image_src: "/generated-wooden-fish-assets/profile/background/image.png" + .to_string(), + image_object_key: "generated-wooden-fish-assets/profile/background/image.png" + .to_string(), + asset_object_id: "background-object-1".to_string(), + generation_provider: "image2".to_string(), + prompt: "èµ›åšèŽ²èŠ±èƒŒæ™¯".to_string(), + width: 1024, + height: 1536, + }), hit_sound_prompt: Some("短促木鱼声".to_string()), hit_sound_asset: Some(WoodenFishAudioAsset { asset_id: "sound-1".to_string(), @@ -386,6 +405,7 @@ mod tests { payload["hitObjectAsset"]["imageObjectKey"], json!("generated-wooden-fish-assets/profile/hit-object/image.png") ); + assert_eq!(payload["backgroundAsset"]["height"], json!(1536)); assert_eq!(payload["hitSoundAsset"]["source"], json!("upload")); assert_eq!(payload["hitSoundAsset"]["durationMs"], json!(800)); } @@ -464,11 +484,13 @@ mod tests { hit_sound_prompt: Some("清脆木鱼".to_string()), floating_words: vec!["功德".to_string()], hit_object_asset: Some(image.clone()), + background_asset: None, hit_sound_asset: Some(audio.clone()), cover_image_src: Some(image.image_src.clone()), generation_status: WoodenFishGenerationStatus::Ready, }, hit_object_asset: image, + background_asset: None, hit_sound_asset: audio, floating_words: vec!["功德".to_string()], }; diff --git a/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs b/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs index 80beca4c..b4edf32f 100644 --- a/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs +++ b/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs @@ -112,6 +112,7 @@ fn map_wooden_fish_work_snapshot( hit_sound_prompt: snapshot.hit_sound_prompt.clone(), floating_words: snapshot.floating_words.clone(), hit_object_asset: snapshot.hit_object_asset.clone().map(map_image_asset), + background_asset: snapshot.background_asset.clone().map(map_image_asset), hit_sound_asset: snapshot.hit_sound_asset.clone().map(map_audio_asset), cover_image_src: empty_string_to_none(snapshot.cover_image_src.clone()), generation_status: parse_generation_status(&snapshot.generation_status), @@ -145,6 +146,7 @@ fn map_wooden_fish_work_snapshot( }, draft, hit_object_asset, + background_asset: snapshot.background_asset.map(map_image_asset), hit_sound_asset, floating_words: snapshot.floating_words, }) @@ -163,6 +165,7 @@ fn map_wooden_fish_draft_snapshot(snapshot: WoodenFishDraftSnapshot) -> WoodenFi hit_sound_prompt: snapshot.hit_sound_prompt, floating_words: snapshot.floating_words, hit_object_asset: snapshot.hit_object_asset.map(map_image_asset), + background_asset: snapshot.background_asset.map(map_image_asset), hit_sound_asset: snapshot.hit_sound_asset.map(map_audio_asset), cover_image_src: snapshot.cover_image_src, generation_status: parse_generation_status(&snapshot.generation_status), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_compile_input_type.rs index d88914d6..402f40ab 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_compile_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_compile_input_type.rs @@ -18,6 +18,7 @@ pub struct WoodenFishDraftCompileInput { pub hit_object_reference_image_src: Option, pub hit_sound_prompt: Option, pub hit_object_asset_json: Option, + pub background_asset_json: Option, pub hit_sound_asset_json: Option, pub floating_words_json: Option, pub cover_image_src: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_snapshot_type.rs index f5f93b09..17c4e2b9 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_draft_snapshot_type.rs @@ -21,6 +21,7 @@ pub struct WoodenFishDraftSnapshot { pub hit_sound_prompt: Option, pub floating_words: Vec, pub hit_object_asset: Option, + pub background_asset: Option, pub hit_sound_asset: Option, pub cover_image_src: Option, pub generation_status: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_row_type.rs index d3d7b30f..ac17e2de 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_gallery_view_row_type.rs @@ -23,6 +23,7 @@ pub struct WoodenFishGalleryViewRow { pub hit_object_reference_image_src: Option, pub hit_sound_prompt: Option, pub hit_object_asset: Option, + pub background_asset: Option, pub hit_sound_asset: Option, pub floating_words: Vec, pub cover_image_src: String, @@ -57,6 +58,8 @@ pub struct WoodenFishGalleryViewRowCols { pub hit_sound_prompt: __sdk::__query_builder::Col>, pub hit_object_asset: __sdk::__query_builder::Col>, + pub background_asset: + __sdk::__query_builder::Col>, pub hit_sound_asset: __sdk::__query_builder::Col>, pub floating_words: __sdk::__query_builder::Col>, @@ -92,6 +95,7 @@ impl __sdk::__query_builder::HasCols for WoodenFishGalleryViewRow { ), hit_sound_prompt: __sdk::__query_builder::Col::new(table_name, "hit_sound_prompt"), hit_object_asset: __sdk::__query_builder::Col::new(table_name, "hit_object_asset"), + background_asset: __sdk::__query_builder::Col::new(table_name, "background_asset"), hit_sound_asset: __sdk::__query_builder::Col::new(table_name, "hit_sound_asset"), floating_words: __sdk::__query_builder::Col::new(table_name, "floating_words"), cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs index 5e692843..c82a9c6c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_profile_row_type.rs @@ -27,6 +27,7 @@ pub struct WoodenFishWorkProfileRow { pub play_count: u32, pub updated_at: __sdk::Timestamp, pub published_at: Option<__sdk::Timestamp>, + pub background_asset_json: Option, } impl __sdk::InModule for WoodenFishWorkProfileRow { @@ -59,6 +60,8 @@ pub struct WoodenFishWorkProfileRowCols { pub updated_at: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col>, + pub background_asset_json: + __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for WoodenFishWorkProfileRow { @@ -100,6 +103,10 @@ impl __sdk::__query_builder::HasCols for WoodenFishWorkProfileRow { play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + background_asset_json: __sdk::__query_builder::Col::new( + table_name, + "background_asset_json", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_snapshot_type.rs index 7c173133..fdaf3116 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_snapshot_type.rs @@ -22,6 +22,7 @@ pub struct WoodenFishWorkSnapshot { pub hit_object_reference_image_src: Option, pub hit_sound_prompt: Option, pub hit_object_asset: Option, + pub background_asset: Option, pub hit_sound_asset: Option, pub floating_words: Vec, pub cover_image_src: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_update_input_type.rs index 2981f8e3..cd7c3547 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_update_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/wooden_fish_work_update_input_type.rs @@ -16,6 +16,7 @@ pub struct WoodenFishWorkUpdateInput { pub hit_object_reference_image_src: Option, pub hit_sound_prompt: Option, pub hit_object_asset_json: Option, + pub background_asset_json: Option, pub hit_sound_asset_json: Option, pub floating_words_json: Option, pub cover_image_src: Option, diff --git a/server-rs/crates/spacetime-client/src/wooden_fish.rs b/server-rs/crates/spacetime-client/src/wooden_fish.rs index 9297a163..edcfd312 100644 --- a/server-rs/crates/spacetime-client/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-client/src/wooden_fish.rs @@ -529,6 +529,9 @@ fn merge_action_into_draft( if let Some(asset) = payload.hit_object_asset.clone() { draft.hit_object_asset = Some(asset); } + if let Some(asset) = payload.background_asset.clone() { + draft.background_asset = Some(asset); + } } if matches!( scope, @@ -573,6 +576,7 @@ fn merge_action_into_draft( && payload.hit_object_asset.is_none() { draft.hit_object_asset = None; + draft.background_asset = None; } if draft.floating_words.is_empty() { draft.floating_words = default_floating_words(); @@ -606,6 +610,9 @@ fn build_compile_input( let hit_sound_asset = draft.hit_sound_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed("wooden fish hit sound asset 缺少真实生æˆèµ„产") })?; + let background_asset = draft.background_asset.clone().ok_or_else(|| { + SpacetimeClientError::validation_failed("wooden fish background asset 缺少真实生æˆèµ„产") + })?; Ok(WoodenFishDraftCompileInput { session_id: current.session_id.clone(), @@ -619,6 +626,7 @@ fn build_compile_input( hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), hit_sound_prompt: draft.hit_sound_prompt.clone(), hit_object_asset_json: Some(json_string(&hit_object_asset)?), + background_asset_json: Some(json_string(&background_asset)?), hit_sound_asset_json: Some(json_string(&hit_sound_asset)?), floating_words_json: Some(json_string(&draft.floating_words)?), cover_image_src: draft.cover_image_src.clone(), @@ -644,6 +652,7 @@ fn build_update_input( hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), hit_sound_prompt: draft.hit_sound_prompt.clone(), hit_object_asset_json: None, + background_asset_json: None, hit_sound_asset_json: if include_hit_sound_asset { draft .hit_sound_asset @@ -710,6 +719,7 @@ fn default_draft() -> WoodenFishDraftResponse { hit_sound_prompt: Some(DEFAULT_HIT_SOUND_PROMPT.to_string()), floating_words: default_floating_words(), hit_object_asset: None, + background_asset: None, hit_sound_asset: None, cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, @@ -796,6 +806,7 @@ mod tests { let session = session_with_draft(draft_without_assets()); let mut payload = action(WoodenFishActionType::CompileDraft); payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object")); + payload.background_asset = Some(generated_background_asset("generated-compile-background")); payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); let (plan, draft) = @@ -822,6 +833,13 @@ mod tests { .unwrap_or("") .contains("generated-compile-sound") ); + assert!( + input + .background_asset_json + .as_deref() + .unwrap_or("") + .contains("generated-compile-background") + ); assert_eq!(draft.generation_status, WoodenFishGenerationStatus::Ready); } @@ -830,6 +848,7 @@ mod tests { let session = session_with_draft(draft_without_assets()); let mut payload = action(WoodenFishActionType::CompileDraft); payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object")); + payload.background_asset = Some(generated_background_asset("generated-compile-background")); let error = match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { @@ -844,12 +863,33 @@ mod tests { ); } + #[test] + fn wooden_fish_compile_requires_real_background_asset_from_api_server() { + let session = session_with_draft(draft_without_assets()); + let mut payload = action(WoodenFishActionType::CompileDraft); + payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object")); + payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); + + let error = + match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { + Ok(_) => panic!("compile-draft should not publish without background asset"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("background asset 缺少真实生æˆèµ„产") + ); + } + #[test] fn wooden_fish_action_regenerate_hit_object_replaces_only_object_asset() { let session = session_with_draft(draft_with_assets()); let mut payload = action(WoodenFishActionType::RegenerateHitObject); payload.hit_object_prompt = Some("新的敲击物".to_string()); payload.hit_object_asset = Some(generated_hit_object_asset("generated-object")); + payload.background_asset = Some(generated_background_asset("generated-background")); let (plan, _draft) = build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) @@ -886,6 +926,13 @@ mod tests { .unwrap_or("") .contains("old-sound") ); + assert!( + input + .background_asset_json + .as_deref() + .unwrap_or("") + .contains("generated-background") + ); } #[test] @@ -930,6 +977,7 @@ mod tests { hit_object_prompt: None, hit_object_reference_image_src: None, hit_object_asset: None, + background_asset: None, hit_sound_prompt: None, hit_sound_asset: None, floating_words: None, @@ -969,6 +1017,21 @@ mod tests { } } + fn generated_background_asset(asset_id: &str) -> WoodenFishImageAsset { + WoodenFishImageAsset { + asset_id: asset_id.to_string(), + image_src: "/generated-wooden-fish-assets/real-profile/background/image.png" + .to_string(), + image_object_key: "generated-wooden-fish-assets/real-profile/background/image.png" + .to_string(), + asset_object_id: format!("{asset_id}-asset"), + generation_provider: "image2".to_string(), + prompt: "新的敲击背景".to_string(), + width: 1024, + height: 1536, + } + } + fn generated_hit_sound_asset(asset_id: &str) -> WoodenFishAudioAsset { WoodenFishAudioAsset { asset_id: asset_id.to_string(), @@ -995,6 +1058,16 @@ mod tests { width: 1024, height: 1024, }), + background_asset: Some(WoodenFishImageAsset { + asset_id: "old-background".to_string(), + image_src: "/generated-wooden-fish-assets/old-background.png".to_string(), + image_object_key: "generated-wooden-fish-assets/old-background.png".to_string(), + asset_object_id: "old-background-asset".to_string(), + generation_provider: "image2".to_string(), + prompt: "旧背景".to_string(), + width: 1024, + height: 1536, + }), hit_sound_asset: Some(WoodenFishAudioAsset { asset_id: "old-sound".to_string(), audio_src: "/generated-wooden-fish-assets/old-sound.mp3".to_string(), @@ -1023,6 +1096,7 @@ mod tests { hit_sound_prompt: Some("旧音效".to_string()), floating_words: default_floating_words(), hit_object_asset: None, + background_asset: None, hit_sound_asset: None, cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 4b630149..c2b2bc4b 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1265,6 +1265,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } + if table_name == "wooden_fish_work_profile" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:敲木鱼背景环境图晚于首版作å“表加入,旧è¿ç§»åŒ…按未生æˆèƒŒæ™¯å…¼å®¹ã€‚ + object + .entry("background_asset_json".to_string()) + .or_insert(serde_json::Value::Null); + } + } next_value } diff --git a/server-rs/crates/spacetime-module/src/wooden_fish.rs b/server-rs/crates/spacetime-module/src/wooden_fish.rs index 7808ce6a..3b3982b2 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish.rs @@ -81,6 +81,7 @@ pub struct WoodenFishGalleryViewRow { pub hit_object_reference_image_src: Option, pub hit_sound_prompt: Option, pub hit_object_asset: Option, + pub background_asset: Option, pub hit_sound_asset: Option, pub floating_words: Vec, pub cover_image_src: String, @@ -327,6 +328,11 @@ fn compile_wooden_fish_draft_tx( .as_deref() .map(parse_json) .transpose()?; + let background_asset = input + .background_asset_json + .as_deref() + .map(parse_json) + .transpose()?; let cover_image_src = input .cover_image_src .as_deref() @@ -354,6 +360,7 @@ fn compile_wooden_fish_draft_tx( hit_sound_prompt: input.hit_sound_prompt.as_deref().and_then(clean_optional), floating_words: floating_words.clone(), hit_object_asset: hit_object_asset.clone(), + background_asset: background_asset.clone(), hit_sound_asset: hit_sound_asset.clone(), cover_image_src: cover_image_src.clone(), generation_status: input @@ -392,6 +399,7 @@ fn compile_wooden_fish_draft_tx( play_count: 0, updated_at: compiled_at, published_at: None, + background_asset_json: background_asset.as_ref().map(to_json_string), }; upsert_work(ctx, row); let config = config_from_draft(&draft); @@ -469,6 +477,14 @@ fn update_wooden_fish_work_tx( let asset = parse_json::(&value)?; next.hit_sound_asset_json = to_json_string(&asset); } + if let Some(value) = input + .background_asset_json + .as_deref() + .and_then(clean_optional) + { + let asset = parse_json::(&value)?; + next.background_asset_json = Some(to_json_string(&asset)); + } if let Some(value) = input .floating_words_json .as_deref() @@ -674,6 +690,7 @@ fn build_gallery_view_row( hit_object_reference_image_src: work.hit_object_reference_image_src, hit_sound_prompt: work.hit_sound_prompt, hit_object_asset: work.hit_object_asset, + background_asset: work.background_asset, hit_sound_asset: work.hit_sound_asset, floating_words: work.floating_words, cover_image_src: work.cover_image_src, @@ -721,6 +738,12 @@ fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result bool { !row.work_title.trim().is_empty() && !row.hit_object_asset_json.trim().is_empty() + && row + .background_asset_json + .as_deref() + .and_then(clean_optional) + .is_some() && !row.hit_sound_asset_json.trim().is_empty() && !row.floating_words_json.trim().is_empty() && row.generation_status == WOODEN_FISH_GENERATION_READY @@ -1002,6 +1030,7 @@ fn draft_from_config( hit_sound_prompt: config.hit_sound_prompt.clone(), floating_words: normalize_floating_words(&config.floating_words), hit_object_asset: None, + background_asset: None, hit_sound_asset: None, cover_image_src: None, generation_status: generation_status.to_string(), @@ -1021,6 +1050,7 @@ fn draft_from_work_snapshot(work: &WoodenFishWorkSnapshot) -> WoodenFishDraftSna hit_sound_prompt: work.hit_sound_prompt.clone(), floating_words: work.floating_words.clone(), hit_object_asset: work.hit_object_asset.clone(), + background_asset: work.background_asset.clone(), hit_sound_asset: work.hit_sound_asset.clone(), cover_image_src: clean_optional(&work.cover_image_src), generation_status: work.generation_status.clone(), @@ -1199,6 +1229,7 @@ fn clone_work(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow { hit_object_reference_image_src: row.hit_object_reference_image_src.clone(), hit_sound_prompt: row.hit_sound_prompt.clone(), hit_object_asset_json: row.hit_object_asset_json.clone(), + background_asset_json: row.background_asset_json.clone(), hit_sound_asset_json: row.hit_sound_asset_json.clone(), floating_words_json: row.floating_words_json.clone(), cover_image_src: row.cover_image_src.clone(), diff --git a/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs b/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs index e7f84193..27899c15 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs @@ -45,6 +45,8 @@ pub struct WoodenFishWorkProfileRow { pub(crate) play_count: u32, pub(crate) updated_at: Timestamp, pub(crate) published_at: Option, + #[default(None::)] + pub(crate) background_asset_json: Option, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/wooden_fish/types.rs b/server-rs/crates/spacetime-module/src/wooden_fish/types.rs index 8b8ea2ea..0bbeef03 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish/types.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish/types.rs @@ -45,6 +45,7 @@ pub struct WoodenFishDraftCompileInput { pub hit_object_reference_image_src: Option, pub hit_sound_prompt: Option, pub hit_object_asset_json: Option, + pub background_asset_json: Option, pub hit_sound_asset_json: Option, pub floating_words_json: Option, pub cover_image_src: Option, @@ -63,6 +64,7 @@ pub struct WoodenFishWorkUpdateInput { pub hit_object_reference_image_src: Option, pub hit_sound_prompt: Option, pub hit_object_asset_json: Option, + pub background_asset_json: Option, pub hit_sound_asset_json: Option, pub floating_words_json: Option, pub cover_image_src: Option, @@ -207,6 +209,7 @@ pub struct WoodenFishDraftSnapshot { pub hit_sound_prompt: Option, pub floating_words: Vec, pub hit_object_asset: Option, + pub background_asset: Option, pub hit_sound_asset: Option, pub cover_image_src: Option, pub generation_status: String, @@ -242,6 +245,7 @@ pub struct WoodenFishWorkSnapshot { pub hit_object_reference_image_src: Option, pub hit_sound_prompt: Option, pub hit_object_asset: Option, + pub background_asset: Option, pub hit_sound_asset: Option, pub floating_words: Vec, pub cover_image_src: String, diff --git a/src/components/wooden-fish-result/WoodenFishResultView.tsx b/src/components/wooden-fish-result/WoodenFishResultView.tsx index f4118fe5..33402a90 100644 --- a/src/components/wooden-fish-result/WoodenFishResultView.tsx +++ b/src/components/wooden-fish-result/WoodenFishResultView.tsx @@ -54,6 +54,10 @@ export function WoodenFishResultView({ : draft.hitObjectAsset; const hitObjectSrc = hitObjectAsset?.imageSrc?.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC; + const backgroundAsset = isWorkProfile + ? profile.backgroundAsset ?? draft.backgroundAsset + : draft.backgroundAsset; + const backgroundSrc = backgroundAsset?.imageSrc?.trim() || ''; const hitSoundAsset = isWorkProfile ? profile.hitSoundAsset : draft.hitSoundAsset; @@ -118,13 +122,24 @@ export function WoodenFishResultView({ {description} ) : null} -
- +
+
+ {backgroundSrc ? ( +
diff --git a/src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx b/src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx index 4bcb2417..d8b230da 100644 --- a/src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx +++ b/src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx @@ -95,6 +95,10 @@ export function WoodenFishRuntimeShell({ profile?.hitObjectAsset?.imageSrc?.trim() || profile?.draft.hitObjectAsset?.imageSrc?.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC; + const backgroundSrc = + profile?.backgroundAsset?.imageSrc?.trim() || + profile?.draft.backgroundAsset?.imageSrc?.trim() || + ''; const hitSoundSrc = profile?.hitSoundAsset?.audioSrc ?? profile?.draft.hitSoundAsset?.audioSrc; const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(hitSoundSrc); @@ -217,6 +221,16 @@ export function WoodenFishRuntimeShell({ onPointerDown={registerTap} >
+ {backgroundSrc ? ( +