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 ? ( +