feat: refine wooden fish runtime generation
This commit is contained in:
@@ -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、阶段和重试属性聚合排障。
|
||||
|
||||
@@ -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/<db>/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` 语法而被完整联调链路拖慢。
|
||||
|
||||
@@ -28,6 +28,10 @@ _Avoid_: 长期功德账本、排行榜玩法、全局账户累计
|
||||
敲木鱼作品中被玩家点击敲击的单张物品图案;默认模板使用内置透明 PNG `/wooden-fish/default-hit-object.png`,用户自定义关键词或上传图时再使用 image2 生成最终资产,上传图只作为 image2 参考。
|
||||
_Avoid_: 直接把上传图作为运行态素材、系列素材图集
|
||||
|
||||
**敲木鱼背景环境图**:
|
||||
敲木鱼作品中的竖屏 9:16 背景资产;由后端在敲击物图案生成后,以新敲击物图案作为主题和画风参考,再结合用户原始题材关键词或参考图主题调用 image2 生成。背景只适配敲击物主题和画风,不包含敲击物本体或木槌互动物品。
|
||||
_Avoid_: 把背景当封面图、在背景里重复绘制敲击物、让前端临时拼背景
|
||||
|
||||
**敲击音效**:
|
||||
敲木鱼作品中每次有效敲击播放的短音频资产,可由描述生成、文件上传或麦克风录制产生,最终统一写回作品的敲击音效资产槽位。
|
||||
_Avoid_: 背景音乐、长音频轨道、运行态实时录音
|
||||
|
||||
@@ -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` 通过。
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 生产运维
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,17 +380,41 @@ pub(crate) async fn create_openai_image_edit(
|
||||
reference_image: &OpenAiReferenceImage,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
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<OpenAiGeneratedImages, AppError> {
|
||||
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(
|
||||
|
||||
@@ -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<AppState>,
|
||||
@@ -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,25 +651,43 @@ 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<WoodenFishImageAsset, AppError> {
|
||||
hit_object_reference_image_src: Option<&str>,
|
||||
) -> Result<WoodenFishGeneratedImageAssets, AppError> {
|
||||
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,
|
||||
final_prompt.as_str(),
|
||||
Some(build_wooden_fish_hit_object_negative_prompt().as_str()),
|
||||
"1024x1024",
|
||||
1,
|
||||
reference_images,
|
||||
hit_object_prompt.as_str(),
|
||||
None,
|
||||
"1:1",
|
||||
reference_images.as_slice(),
|
||||
"生成敲木鱼敲击物图案失败",
|
||||
)
|
||||
.await?;
|
||||
@@ -684,35 +698,182 @@ async fn generate_wooden_fish_hit_object_asset(
|
||||
"message": "生成敲木鱼敲击物图案失败:上游未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let generated_at_micros = current_utc_micros();
|
||||
let persisted = persist_wooden_fish_hit_object_asset(
|
||||
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(),
|
||||
&final_prompt,
|
||||
hit_object_prompt.as_str(),
|
||||
image,
|
||||
generated_at_micros,
|
||||
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,
|
||||
background_prompt.as_str(),
|
||||
None,
|
||||
"9:16",
|
||||
&background_reference_image,
|
||||
"生成敲木鱼背景环境图失败",
|
||||
)
|
||||
.await?;
|
||||
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,
|
||||
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<OpenAiReferenceImage, AppError> {
|
||||
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<Option<OpenAiReferenceImage>, 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<WoodenFishImageAsset, AppError> {
|
||||
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("赛博莲花木鱼"));
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ pub struct WoodenFishActionRequest {
|
||||
#[serde(default, skip_deserializing)]
|
||||
pub hit_object_asset: Option<WoodenFishImageAsset>,
|
||||
#[serde(default)]
|
||||
#[serde(skip_deserializing)]
|
||||
pub background_asset: Option<WoodenFishImageAsset>,
|
||||
#[serde(default)]
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAsset>,
|
||||
@@ -123,6 +126,8 @@ pub struct WoodenFishDraftResponse {
|
||||
#[serde(default)]
|
||||
pub hit_object_asset: Option<WoodenFishImageAsset>,
|
||||
#[serde(default)]
|
||||
pub background_asset: Option<WoodenFishImageAsset>,
|
||||
#[serde(default)]
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAsset>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
@@ -185,6 +190,8 @@ pub struct WoodenFishWorkProfileResponse {
|
||||
pub summary: WoodenFishWorkSummaryResponse,
|
||||
pub draft: WoodenFishDraftResponse,
|
||||
pub hit_object_asset: WoodenFishImageAsset,
|
||||
#[serde(default)]
|
||||
pub background_asset: Option<WoodenFishImageAsset>,
|
||||
pub hit_sound_asset: WoodenFishAudioAsset,
|
||||
pub floating_words: Vec<String>,
|
||||
}
|
||||
@@ -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()],
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct WoodenFishDraftCompileInput {
|
||||
pub hit_object_reference_image_src: Option<String>,
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset_json: Option<String>,
|
||||
pub background_asset_json: Option<String>,
|
||||
pub hit_sound_asset_json: Option<String>,
|
||||
pub floating_words_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
|
||||
@@ -21,6 +21,7 @@ pub struct WoodenFishDraftSnapshot {
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub floating_words: Vec<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub generation_status: String,
|
||||
|
||||
@@ -23,6 +23,7 @@ pub struct WoodenFishGalleryViewRow {
|
||||
pub hit_object_reference_image_src: Option<String>,
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub floating_words: Vec<String>,
|
||||
pub cover_image_src: String,
|
||||
@@ -57,6 +58,8 @@ pub struct WoodenFishGalleryViewRowCols {
|
||||
pub hit_sound_prompt: __sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<String>>,
|
||||
pub hit_object_asset:
|
||||
__sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<WoodenFishImageAssetSnapshot>>,
|
||||
pub background_asset:
|
||||
__sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<WoodenFishImageAssetSnapshot>>,
|
||||
pub hit_sound_asset:
|
||||
__sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<WoodenFishAudioAssetSnapshot>>,
|
||||
pub floating_words: __sdk::__query_builder::Col<WoodenFishGalleryViewRow, Vec<String>>,
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for WoodenFishWorkProfileRow {
|
||||
@@ -59,6 +60,8 @@ pub struct WoodenFishWorkProfileRowCols {
|
||||
pub updated_at: __sdk::__query_builder::Col<WoodenFishWorkProfileRow, __sdk::Timestamp>,
|
||||
pub published_at:
|
||||
__sdk::__query_builder::Col<WoodenFishWorkProfileRow, Option<__sdk::Timestamp>>,
|
||||
pub background_asset_json:
|
||||
__sdk::__query_builder::Col<WoodenFishWorkProfileRow, Option<String>>,
|
||||
}
|
||||
|
||||
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",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct WoodenFishWorkSnapshot {
|
||||
pub hit_object_reference_image_src: Option<String>,
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub floating_words: Vec<String>,
|
||||
pub cover_image_src: String,
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct WoodenFishWorkUpdateInput {
|
||||
pub hit_object_reference_image_src: Option<String>,
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset_json: Option<String>,
|
||||
pub background_asset_json: Option<String>,
|
||||
pub hit_sound_asset_json: Option<String>,
|
||||
pub floating_words_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ pub struct WoodenFishGalleryViewRow {
|
||||
pub hit_object_reference_image_src: Option<String>,
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub floating_words: Vec<String>,
|
||||
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::<WoodenFishAudioAssetSnapshot>(&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::<WoodenFishImageAssetSnapshot>(&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<WoodenFishWorkS
|
||||
hit_object_asset: clean_optional(&row.hit_object_asset_json)
|
||||
.map(|value| parse_json(&value))
|
||||
.transpose()?,
|
||||
background_asset: row
|
||||
.background_asset_json
|
||||
.as_deref()
|
||||
.and_then(clean_optional)
|
||||
.map(|value| parse_json(&value))
|
||||
.transpose()?,
|
||||
hit_sound_asset: clean_optional(&row.hit_sound_asset_json)
|
||||
.map(|value| parse_json(&value))
|
||||
.transpose()?,
|
||||
@@ -965,6 +988,11 @@ fn insert_event(
|
||||
fn is_publish_ready(row: &WoodenFishWorkProfileRow) -> 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(),
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct WoodenFishWorkProfileRow {
|
||||
pub(crate) play_count: u32,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
pub(crate) published_at: Option<Timestamp>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) background_asset_json: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct WoodenFishDraftCompileInput {
|
||||
pub hit_object_reference_image_src: Option<String>,
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset_json: Option<String>,
|
||||
pub background_asset_json: Option<String>,
|
||||
pub hit_sound_asset_json: Option<String>,
|
||||
pub floating_words_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
@@ -63,6 +64,7 @@ pub struct WoodenFishWorkUpdateInput {
|
||||
pub hit_object_reference_image_src: Option<String>,
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset_json: Option<String>,
|
||||
pub background_asset_json: Option<String>,
|
||||
pub hit_sound_asset_json: Option<String>,
|
||||
pub floating_words_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
@@ -207,6 +209,7 @@ pub struct WoodenFishDraftSnapshot {
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub floating_words: Vec<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub generation_status: String,
|
||||
@@ -242,6 +245,7 @@ pub struct WoodenFishWorkSnapshot {
|
||||
pub hit_object_reference_image_src: Option<String>,
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub floating_words: Vec<String>,
|
||||
pub cover_image_src: String,
|
||||
|
||||
@@ -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,14 +122,25 @@ export function WoodenFishResultView({
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 grid min-h-0 flex-1 place-items-center rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/78 p-5">
|
||||
<div className="mt-4 grid min-h-0 flex-1 place-items-center">
|
||||
<div className="relative grid aspect-[9/16] h-full max-h-[min(58vh,34rem)] w-full max-w-[20rem] place-items-center overflow-hidden rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/78">
|
||||
{backgroundSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={backgroundSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-white/10" />
|
||||
<ResolvedAssetImage
|
||||
src={hitObjectSrc}
|
||||
fallbackSrc={WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC}
|
||||
alt="敲击物图案"
|
||||
className="max-h-[min(46vh,22rem)] w-full object-contain drop-shadow-[0_18px_28px_rgba(15,23,42,0.18)]"
|
||||
className="relative z-10 w-[68%] object-contain drop-shadow-[0_18px_28px_rgba(15,23,42,0.18)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel flex min-h-0 flex-col rounded-[1.25rem] p-4">
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.92),transparent_26%),linear-gradient(180deg,#fff8e8_0%,#eef7ed_55%,#e5f2f7_100%)]" />
|
||||
{backgroundSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={backgroundSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-0 bg-white/10" />
|
||||
|
||||
<header
|
||||
data-wooden-fish-functional="true"
|
||||
|
||||
@@ -356,7 +356,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('wooden fish draft generation exposes hit object and sound pipeline', () => {
|
||||
test('wooden fish draft generation exposes hit object, background and sound pipeline', () => {
|
||||
const state = createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
@@ -367,6 +367,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'wooden-fish-draft',
|
||||
'wooden-fish-hit-object',
|
||||
'wooden-fish-background',
|
||||
'wooden-fish-hit-sound',
|
||||
'wooden-fish-write-draft',
|
||||
]);
|
||||
|
||||
@@ -66,6 +66,7 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'jump-hop-write-draft'
|
||||
| 'wooden-fish-draft'
|
||||
| 'wooden-fish-hit-object'
|
||||
| 'wooden-fish-background'
|
||||
| 'wooden-fish-hit-sound'
|
||||
| 'wooden-fish-write-draft'
|
||||
| 'puzzle-images'
|
||||
@@ -327,25 +328,31 @@ const WOODEN_FISH_STEPS = [
|
||||
id: 'wooden-fish-draft',
|
||||
label: '整理玩法草稿',
|
||||
detail: '保存作品信息、敲击物、音效和飘字配置。',
|
||||
weight: 10,
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-hit-object',
|
||||
label: '生成敲击物图案',
|
||||
detail: '使用 image2 生成最终运行态敲击物图案。',
|
||||
weight: 48,
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-background',
|
||||
label: '生成背景环境图',
|
||||
detail: '使用 image2 生成敲击背景环境图。',
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-hit-sound',
|
||||
label: '准备敲击音效',
|
||||
detail: '生成或写回短促敲击音效资产。',
|
||||
weight: 30,
|
||||
weight: 16,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-write-draft',
|
||||
label: '写入正式草稿',
|
||||
detail: '保存图案、音效、飘字和封面摘要。',
|
||||
weight: 12,
|
||||
detail: '保存图案、背景、音效、飘字和封面摘要。',
|
||||
weight: 8,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
@@ -530,9 +537,12 @@ function resolveWoodenFishPhaseByElapsedMs(
|
||||
if (elapsedMs >= 270_000) {
|
||||
return 'wooden-fish-write-draft';
|
||||
}
|
||||
if (elapsedMs >= 185_000) {
|
||||
if (elapsedMs >= 240_000) {
|
||||
return 'wooden-fish-hit-sound';
|
||||
}
|
||||
if (elapsedMs >= 120_000) {
|
||||
return 'wooden-fish-background';
|
||||
}
|
||||
if (elapsedMs >= 12_000) {
|
||||
return 'wooden-fish-hit-object';
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ function normalizeWoodenFishWorkProfile(
|
||||
summary,
|
||||
draft: flattened.draft,
|
||||
hitObjectAsset: flattened.hitObjectAsset,
|
||||
backgroundAsset:
|
||||
flattened.backgroundAsset ?? flattened.draft?.backgroundAsset ?? null,
|
||||
hitSoundAsset: flattened.hitSoundAsset,
|
||||
floatingWords: flattened.floatingWords,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user