From 44c65df5c914ebaf9a249246d26316170ff9e010 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 26 May 2026 13:18:13 +0800 Subject: [PATCH 1/5] refactor: extract platform media crates --- .hermes/shared-memory/decision-log.md | 20 +- .hermes/shared-memory/pitfalls.md | 30 +- ...】复杂媒体资产链路Adapter扩展计划-2026-05-14.md | 4 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 8 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- server-rs/Cargo.lock | 27 + server-rs/Cargo.toml | 4 + server-rs/crates/api-server/Cargo.toml | 2 + server-rs/crates/api-server/src/auth.rs | 4 +- .../crates/api-server/src/bark_battle.rs | 5 +- .../api-server/src/creation_entry_config.rs | 8 +- .../api-server/src/external_api_audit.rs | 2 +- .../api-server/src/generated_asset_sheets.rs | 1754 +---------------- .../api-server/src/generated_image_assets.rs | 27 +- .../api-server/src/hyper3d_generation.rs | 1144 +---------- server-rs/crates/api-server/src/jump_hop.rs | 145 +- .../crates/api-server/src/modules/auth.rs | 5 +- .../api-server/src/modules/bark_battle.rs | 8 +- .../api-server/src/puzzle/vector_engine.rs | 38 +- .../src/vector_engine_audio_generation.rs | 1527 +------------- .../vector_engine_audio_generation/clock.rs | 8 + .../vector_engine_audio_generation/errors.rs | 120 ++ .../generation.rs | 122 ++ .../handlers.rs | 216 ++ .../vector_engine_audio_generation/persist.rs | 115 ++ .../vector_engine_audio_generation/publish.rs | 164 ++ .../settings.rs | 44 + .../vector_engine_audio_generation/targets.rs | 52 + .../vector_engine_audio_generation/tasks.rs | 69 + .../vector_engine_audio_generation/tests.rs | 79 + .../vector_engine_audio_generation/types.rs | 77 + .../crates/api-server/src/wooden_fish.rs | 34 +- server-rs/crates/module-runtime/src/lib.rs | 5 +- server-rs/crates/platform-audio/Cargo.toml | 13 + server-rs/crates/platform-audio/src/client.rs | 255 +++ .../crates/platform-audio/src/download.rs | 118 ++ server-rs/crates/platform-audio/src/error.rs | 167 ++ server-rs/crates/platform-audio/src/lib.rs | 32 + .../crates/platform-audio/src/persist.rs | 106 + .../crates/platform-audio/src/request.rs | 94 + .../crates/platform-audio/src/response.rs | 125 ++ server-rs/crates/platform-audio/src/types.rs | 87 + .../tests/vector_engine_audio.rs | 84 + server-rs/crates/platform-auth/src/lib.rs | 25 +- server-rs/crates/platform-hyper3d/Cargo.toml | 14 + .../crates/platform-hyper3d/src/client.rs | 179 ++ .../crates/platform-hyper3d/src/error.rs | 180 ++ server-rs/crates/platform-hyper3d/src/lib.rs | 13 + .../crates/platform-hyper3d/src/request.rs | 14 + .../src/request/image_data_url.rs | 61 + .../platform-hyper3d/src/request/normalize.rs | 119 ++ .../platform-hyper3d/src/request/options.rs | 111 ++ .../platform-hyper3d/src/request/tests.rs | 64 + .../crates/platform-hyper3d/src/response.rs | 11 + .../src/response/downloads.rs | 67 + .../platform-hyper3d/src/response/parsing.rs | 159 ++ .../platform-hyper3d/src/response/status.rs | 69 + .../platform-hyper3d/src/response/submit.rs | 64 + .../platform-hyper3d/src/response/tests.rs | 88 + .../crates/platform-hyper3d/src/transport.rs | 99 + .../crates/platform-hyper3d/src/types.rs | 37 + .../platform-hyper3d/tests/hyper3d_rodin.rs | 61 + server-rs/crates/platform-image/Cargo.toml | 2 + .../src/generated_asset_sheets/alpha.rs | 429 ++++ .../src/generated_asset_sheets/color.rs | 162 ++ .../src/generated_asset_sheets/error.rs | 62 + .../src/generated_asset_sheets/mod.rs | 18 + .../src/generated_asset_sheets/persist.rs | 203 ++ .../src/generated_asset_sheets/prompt.rs | 65 + .../src/generated_asset_sheets/sheet.rs | 672 +++++++ .../platform-image/src/generated_assets.rs | 2 + .../src/generated_assets}/adapter.rs | 53 +- .../src/generated_assets}/helpers.rs | 69 +- server-rs/crates/platform-image/src/lib.rs | 1375 +------------ .../platform-image/src/vector_engine/audit.rs | 64 + .../src/vector_engine/client.rs | 245 +++ .../src/vector_engine/constants.rs | 3 + .../platform-image/src/vector_engine/error.rs | 114 ++ .../src/vector_engine/image_source.rs | 248 +++ .../platform-image/src/vector_engine/mod.rs | 26 + .../src/vector_engine/payload.rs | 128 ++ .../src/vector_engine/request.rs | 69 + .../src/vector_engine/response.rs | 180 ++ .../platform-image/src/vector_engine/tests.rs | 177 ++ .../src/vector_engine/transport.rs | 78 + .../platform-image/src/vector_engine/types.rs | 27 + .../platform-image/src/vector_engine/util.rs | 89 + .../tests/generated_asset_sheets.rs | 229 +++ .../platform-image/tests/vector_engine.rs | 32 + .../shared-contracts/src/wooden_fish.rs | 3 +- .../crates/spacetime-client/src/jump_hop.rs | 15 +- .../spacetime-module/src/bark_battle.rs | 5 +- 92 files changed, 7381 insertions(+), 5848 deletions(-) create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/clock.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/errors.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/settings.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs create mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs create mode 100644 server-rs/crates/platform-audio/Cargo.toml create mode 100644 server-rs/crates/platform-audio/src/client.rs create mode 100644 server-rs/crates/platform-audio/src/download.rs create mode 100644 server-rs/crates/platform-audio/src/error.rs create mode 100644 server-rs/crates/platform-audio/src/lib.rs create mode 100644 server-rs/crates/platform-audio/src/persist.rs create mode 100644 server-rs/crates/platform-audio/src/request.rs create mode 100644 server-rs/crates/platform-audio/src/response.rs create mode 100644 server-rs/crates/platform-audio/src/types.rs create mode 100644 server-rs/crates/platform-audio/tests/vector_engine_audio.rs create mode 100644 server-rs/crates/platform-hyper3d/Cargo.toml create mode 100644 server-rs/crates/platform-hyper3d/src/client.rs create mode 100644 server-rs/crates/platform-hyper3d/src/error.rs create mode 100644 server-rs/crates/platform-hyper3d/src/lib.rs create mode 100644 server-rs/crates/platform-hyper3d/src/request.rs create mode 100644 server-rs/crates/platform-hyper3d/src/request/image_data_url.rs create mode 100644 server-rs/crates/platform-hyper3d/src/request/normalize.rs create mode 100644 server-rs/crates/platform-hyper3d/src/request/options.rs create mode 100644 server-rs/crates/platform-hyper3d/src/request/tests.rs create mode 100644 server-rs/crates/platform-hyper3d/src/response.rs create mode 100644 server-rs/crates/platform-hyper3d/src/response/downloads.rs create mode 100644 server-rs/crates/platform-hyper3d/src/response/parsing.rs create mode 100644 server-rs/crates/platform-hyper3d/src/response/status.rs create mode 100644 server-rs/crates/platform-hyper3d/src/response/submit.rs create mode 100644 server-rs/crates/platform-hyper3d/src/response/tests.rs create mode 100644 server-rs/crates/platform-hyper3d/src/transport.rs create mode 100644 server-rs/crates/platform-hyper3d/src/types.rs create mode 100644 server-rs/crates/platform-hyper3d/tests/hyper3d_rodin.rs create mode 100644 server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs create mode 100644 server-rs/crates/platform-image/src/generated_asset_sheets/color.rs create mode 100644 server-rs/crates/platform-image/src/generated_asset_sheets/error.rs create mode 100644 server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs create mode 100644 server-rs/crates/platform-image/src/generated_asset_sheets/persist.rs create mode 100644 server-rs/crates/platform-image/src/generated_asset_sheets/prompt.rs create mode 100644 server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs create mode 100644 server-rs/crates/platform-image/src/generated_assets.rs rename server-rs/crates/{api-server/src/generated_image_assets => platform-image/src/generated_assets}/adapter.rs (75%) rename server-rs/crates/{api-server/src/generated_image_assets => platform-image/src/generated_assets}/helpers.rs (84%) create mode 100644 server-rs/crates/platform-image/src/vector_engine/audit.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/client.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/constants.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/error.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/image_source.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/mod.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/payload.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/request.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/response.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/tests.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/transport.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/types.rs create mode 100644 server-rs/crates/platform-image/src/vector_engine/util.rs create mode 100644 server-rs/crates/platform-image/tests/generated_asset_sheets.rs create mode 100644 server-rs/crates/platform-image/tests/vector_engine.rs diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b40920eb..c4a20efe 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -112,6 +112,14 @@ - 验证方式:执行 `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-25 通用系列素材图集实现下沉到 platform-image + +- 背景:`generated_asset_sheets` 同时承载 sheet prompt、切图、绿幕去背、边缘 matte 清理和 OSS 持久化准备,长期放在 `api-server` 会把多个玩法的图片 seam 继续绑死在 HTTP crate 上。 +- 决策:通用系列素材图集的实现真值源下沉到 `platform-image::generated_asset_sheets`,`api-server::generated_asset_sheets` 只保留 `AppState` / `AppError` 适配与调用方兼容导出,不再承载图像处理和 OSS 请求构造细节。 +- 影响范围:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`server-rs/crates/api-server/src/match3d/item_assets.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`cargo test -p platform-image --test generated_asset_sheets --manifest-path server-rs/Cargo.toml` 与 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 通过;调用方继续通过 `api-server` 的薄包装访问同一组能力。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-22 敲木鱼敲击物暂不做服务端抠图后处理 - 背景:gpt-image-2 偶尔会把木鱼图直接回成带黑底或其它实底背景的 PNG,但服务端抠图后处理在玉米等主题上误伤过主体像素。 @@ -140,11 +148,19 @@ ## 2026-05-25 VectorEngine 图片 provider 收到 platform-image - 背景:`api-server` 里原本同时混着 VectorEngine 创建 / 编辑协议、响应解析、远端图片下载、失败日志和审计落库逻辑,Puzzle / Match3D 还各自藏着一份近似实现,导致“provider 协议”和“业务编排”边界不清。 -- 决策:把 VectorEngine `gpt-image-2` 图片 provider 协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志统一收口到 `server-rs/crates/platform-image`。`api-server` 只保留配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计桥接;旧 `openai_image_generation.rs` 只作为兼容转接层,不再承担 provider 实现。 +- 决策:把 VectorEngine `gpt-image-2` 图片 provider 协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志统一收口到 `server-rs/crates/platform-image/src/vector_engine/`,并按 `client.rs`、`transport.rs`、`request.rs`、`payload.rs`、`response.rs`、`image_source.rs` 等小模块拆分,避免把大文件从 `api-server` 平移到平台 crate。`api-server` 只保留配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计桥接;旧 `openai_image_generation.rs` 只作为兼容转接层,不再承担 provider 实现。 - 影响范围:`server-rs/crates/platform-image`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、后端架构与运维文档。 -- 验证方式:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 验证方式:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image --test vector_engine --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-05-26 音频 provider 协议收口到 platform-audio,Hyper3D 继续保持薄代理 + +- 背景:`api-server/src/vector_engine_audio_generation.rs` 和 `api-server/src/hyper3d_generation.rs` 仍然承担太多 provider 细节,容易把外部协议、下载、解析和 BFF 编排混在一起。 +- 决策:VectorEngine Suno/Vidu 音频协议、任务提交/轮询、下载和 OSS 持久化请求准备收口到 `platform-audio`,并继续按 `client.rs`、`request.rs`、`response.rs`、`download.rs`、`persist.rs`、`error.rs` 拆小模块;`api-server` 只保留路由、配置、计费、asset_object confirm、entity binding 和错误映射。Hyper3D 维持后端安全代理和旧数据兼容,`platform-hyper3d` 承接 Rodin 的协议与解析,`api-server` 仅做薄 wrapper。 +- 影响范围:`server-rs/crates/platform-audio/`、`server-rs/crates/platform-hyper3d/`、`server-rs/crates/api-server/src/vector_engine_audio_generation.rs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、相关后端架构文档。 +- 验证方式:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-hyper3d --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 通过;`api-server` 不再包含音频 provider 协议和 Hyper3D parser 主实现。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md`。 + ## 2026-05-21 拼图参考图主链改为 OSS assetObjectId 与只读签名 URL - 背景:release 上拼图图生图生成草稿时,旧链路把上传图转成 Data URL/base64 放进创作 action JSON body,容易先触发 Nginx `413 Request Entity Too Large`,也让外部模型调用前的 HTTP body 过大。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index eac362e4..c9f1c0d1 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -118,6 +118,14 @@ - 验证:`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx` 应覆盖“运行态不把兼容写入的UI spritesheet当中心容器图”。 - 关联:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`server-rs/crates/api-server/src/match3d/mappers.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 通用系列素材图集先看 platform-image,不要先翻 api-server 大文件 + +- 现象:排查跳一跳、抓大鹅或其它玩法的系列素材图集切片 / 去绿 / 持久化时,最容易先打开 `api-server/src/generated_asset_sheets.rs`,结果在一个 60KB+ 大文件里找实现、测试和辅助函数,定位很慢。 +- 原因:这条通用图片 seam 已经下沉到 `server-rs/crates/platform-image/src/generated_asset_sheets/`,`api-server` 只剩薄包装和调用方兼容;继续把 `api-server` 当真值源会把理解路径拉回旧位置。 +- 处理:先看 `server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs`、`prompt.rs`、`sheet.rs`、`alpha.rs`、`persist.rs` 和 `error.rs`,再看 `api-server/src/generated_asset_sheets.rs` 的 AppError / AppState 适配和玩法调用点。 +- 验证:`cargo test -p platform-image --test generated_asset_sheets --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。 +- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## UI spritesheet 不要依赖模型直接生成透明背景 - 现象:拼图或抓大鹅运行态解析 UI spritesheet 时,把整张背景图、棋盘格、叶子或装饰图也当作 UI 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。 @@ -285,9 +293,25 @@ - 现象:排查拼图或其它玩法的生图失败时,如果直接在 `api-server` 的大文件里找 `images/generations`、`images/edits`、base64 解码或下载逻辑,会看到很多历史 helper 和测试桥,看起来像每个玩法都自带一份 provider 实现。 - 原因:旧实现把 VectorEngine 图片 provider 协议、响应解析、下载和日志混在 `api-server` 里,后来虽然迁出到 `platform-image`,但兼容层和测试 helper 仍会让人误判真相源位置。 -- 处理:先看 `server-rs/crates/platform-image/src/lib.rs` 的 provider 协议和结构化日志,再看 `server-rs/crates/api-server/src/openai_image_generation.rs` 的兼容桥和 `external_api_audit.rs` 的落库映射;`puzzle/vector_engine.rs` 只保留玩法编排,不再作为 provider 协议真相源。 -- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml -- --nocapture` 通过时,排障先按 `platform-image` 的日志字段查 provider / endpoint / failure_stage。 -- 关联:`server-rs/crates/platform-image/src/lib.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`。 +- 处理:先看 `server-rs/crates/platform-image/src/vector_engine/`:`request.rs` 查路径和请求体,`client.rs` 查生成 / 编辑编排,`transport.rs` 查 HTTP client 与 reqwest 错误归一,`payload.rs` 查响应字段提取,`response.rs` 查上游状态、解析、缺图和下载分流,`image_source.rs` 查参考图和远端图片下载。再看 `server-rs/crates/api-server/src/openai_image_generation.rs` 的兼容桥和 `external_api_audit.rs` 的落库映射;`puzzle/vector_engine.rs` 只保留玩法编排,不再作为 provider 协议真相源。 +- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image --test vector_engine --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture` 通过时,排障先按 `platform-image` 的日志字段查 provider / endpoint / failure_stage。 +- 关联:`server-rs/crates/platform-image/src/vector_engine/`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`。 + +## 音频 provider 协议先看 platform-audio,不要先翻 api-server 大文件 + +- 现象:排查 Visual Novel 或通用创作音频生成失败时,如果直接打开 `api-server/src/vector_engine_audio_generation.rs`,会同时看到路由、计费、asset binding、下载、解析和 provider 协议,定位时很容易在同一个文件里来回跳。 +- 原因:音频 provider 已经迁到 `server-rs/crates/platform-audio/`,但 `api-server` 仍保留薄 wrapper;如果把 wrapper 当真值源,就会误判边界。 +- 处理:先看 `server-rs/crates/platform-audio/src/client.rs`、`request.rs`、`response.rs`、`download.rs`、`persist.rs`、`error.rs`,再看 `api-server/src/vector_engine_audio_generation.rs` 的路由、配置、计费、asset object confirm 和 entity binding 包裹。 +- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。 +- 关联:`server-rs/crates/platform-audio/`、`server-rs/crates/api-server/src/vector_engine_audio_generation.rs`。 + +## Hyper3D 现在只剩后端薄代理,不要再把协议解析写回 api-server + +- 现象:排查 Hyper3D/Rodin 时,如果继续在 `api-server/src/hyper3d_generation.rs` 里扩协议解析、请求体构造或下载列表处理,文件会重新变厚。 +- 原因:`platform-hyper3d` 已经承接 Rodin 的提交、状态和下载协议解析;`api-server` 只是薄 wrapper 和错误 envelope 映射。 +- 处理:新增或修改 Hyper3D 协议时优先放到 `server-rs/crates/platform-hyper3d/` 的 `client.rs`、`request.rs`、`response.rs`、`transport.rs` 和子模块,`api-server` 只保留鉴权、配置校验和错误映射。 +- 验证:`cargo test -p platform-hyper3d --manifest-path server-rs/Cargo.toml` 通过后再看 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/platform-hyper3d/`、`server-rs/crates/api-server/src/hyper3d_generation.rs`。 ## release 创作接口 413 先查是否还在提交 Data URL diff --git a/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md b/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md index d25fbc9c..09e0bff1 100644 --- a/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md +++ b/docs/technical/【后端架构】复杂媒体资产链路Adapter扩展计划-2026-05-14.md @@ -88,6 +88,10 @@ Adapter 只负责媒体持久化和资产绑定,不负责: | Custom World opening CG video | `custom_world_ai.rs` 或后续分层文件 | video | Ark/火山视频 task 结果 URL | 视频下载、OSS、confirm、binding | storyboard->video 顺序、固定点数计费、超时错误 | storyboard 图片仍走图片 Adapter,最终视频走 media persist | | Character visual reference/workflow | `character_visual_assets.rs` | image/cache metadata | GPT image helper、workflow cache | 可复用 media persist 的 source/OSS/confirm/binding;图片生成 provider 不迁入复杂媒体 | 角色 workflow cache 可空继续生成 | 角色视觉发布链路回包字段不变 | | Character animation publish/import | `character_animation_assets.rs` | video / image sequence | data:video base64、remote video、阶段占位 | data URL/base64 解码、视频 OSS、confirm、binding | stage1 placeholder 语义、import-video contract | 导入视频和发布视频都不再复制 OSS/confirm 代码 | + +2026-05-26 补充:图片生成 provider 不再作为复杂媒体 Adapter 的实现细节散落在 `api-server`。VectorEngine `gpt-image-2` 创建 / 编辑协议、响应解析、URL / base64 图片归一、远端下载和 provider 侧结构化失败日志已经收口到 `server-rs/crates/platform-image/src/vector_engine/`;`api-server/src/openai_image_generation.rs` 只保留配置、兼容调用面和外部失败审计桥接。后续扩展视频、音频或 Hyper3D 时,可以复用“platform crate 承接 provider 协议,api-server 承接 HTTP/BFF、计费、OSS 绑定和失败审计桥接”的分层方式,但不得把新的 provider 协议塞回 `api-server` 大文件。 +2026-05-26 补充:音频生成 provider 协议也不应继续挤在 `api-server/src/vector_engine_audio_generation.rs`。VectorEngine Suno/Vidu 的任务提交、轮询、下载、MIME/extension 归一和 OSS 持久化请求准备已经收口到 `server-rs/crates/platform-audio/`,并继续按 `client.rs`、`request.rs`、`response.rs`、`download.rs`、`persist.rs`、`error.rs` 拆成小模块;`api-server` 只保留路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射,不再承担 provider 协议和下载细节。后续若再增加音频子能力,也必须优先放进平台 crate,而不是扩张 `api-server`。 +2026-05-26 补充:Hyper3D 只保留后端安全代理和旧数据兼容,`api-server/src/hyper3d_generation.rs` 应保持薄 wrapper,`platform-hyper3d` 承接 Rodin 的提交、状态和下载协议解析。若未来要继续压缩这一条线,应优先继续下沉协议解析与 transport helper,而不是把 provider 逻辑回流到 `api-server`。 | Match3D 背景音乐 | `match3d.rs` | audio | 现有生成/上传链路 | 仅复用音频持久化 | 不恢复 Rodin/GLB 新草稿回退 | 图片素材仍按图片 Adapter 计划处理 | | Puzzle 背景音乐 | `puzzle.rs` | audio | 现有生成/上传链路 | 仅复用音频持久化 | puzzle 运行态和排行榜语义不变 | `puzzle_background_music` kind/binding 不变 | | Hyper3D/GLB 历史代理 | `hyper3d.rs` | model/glb | Hyper3D Rodin status/download | 如存在转存需求,仅复用 media persist | Match3D 新草稿禁止回退 Rodin/GLB | 历史代理 route contract 不变 | diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 087e28b9..24a75120 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -119,10 +119,10 @@ npm run check:server-rs-ddd 2. Adapter 输入应显式包含 provider、prompt、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source job、metadata 和可选透明背景后处理。 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 -5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image`;`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 +5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 6. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 7. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 -8. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 +8. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 ## SpacetimeDB schema 变更规则 @@ -164,8 +164,8 @@ npm run check:server-rs-ddd - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 -- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;新 Match3D 草稿和批量新增不再生成 GLB。 -- 音频:视觉小说专用音频路由保留;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 +- Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 +- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 - OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。 - 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 5044d3fe..0e593209 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -30,7 +30,7 @@ 单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图和删除确认;新玩法页面不得重复手写这些交互。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec`、`slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。 -`api-server` 的 `generated_asset_sheets` 是当前通用系列素材图集模块:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。 +通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets`:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。 当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index de0181aa..becd7daa 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -107,7 +107,9 @@ dependencies = [ "module-visual-novel", "opentelemetry", "platform-agent", + "platform-audio", "platform-auth", + "platform-hyper3d", "platform-image", "platform-llm", "platform-oss", @@ -2301,6 +2303,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "platform-audio" +version = "0.1.0" +dependencies = [ + "platform-oss", + "reqwest 0.12.28", + "serde_json", + "tokio", + "tracing", + "urlencoding", +] + [[package]] name = "platform-auth" version = "0.1.0" @@ -2322,11 +2336,24 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "platform-hyper3d" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "reqwest 0.12.28", + "serde_json", + "shared-contracts", + "tokio", +] + [[package]] name = "platform-image" version = "0.1.0" dependencies = [ "base64 0.22.1", + "image", + "platform-oss", "reqwest 0.12.28", "serde_json", "tokio", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 66f2a2db..84e0da98 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -32,6 +32,8 @@ members = [ "crates/module-visual-novel", "crates/platform-oss", "crates/platform-auth", + "crates/platform-audio", + "crates/platform-hyper3d", "crates/platform-image", "crates/platform-llm", "crates/platform-speech", @@ -75,6 +77,8 @@ module-story = { path = "crates/module-story", default-features = false } module-visual-novel = { path = "crates/module-visual-novel", default-features = false } platform-agent = { path = "crates/platform-agent", default-features = false } platform-auth = { path = "crates/platform-auth", default-features = false } +platform-audio = { path = "crates/platform-audio", default-features = false } +platform-hyper3d = { path = "crates/platform-hyper3d", default-features = false } platform-image = { path = "crates/platform-image", default-features = false } platform-llm = { path = "crates/platform-llm", default-features = false } platform-oss = { path = "crates/platform-oss", default-features = false } diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 2844c4da..06aaaa61 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -33,7 +33,9 @@ module-square-hole = { workspace = true } module-story = { workspace = true } module-visual-novel = { workspace = true } platform-agent = { workspace = true } +platform-audio = { workspace = true } platform-auth = { workspace = true } +platform-hyper3d = { workspace = true } platform-image = { workspace = true } platform-llm = { workspace = true } platform-oss = { workspace = true } diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index 1b27e0a1..75626047 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -9,8 +9,8 @@ use axum::{ response::Response, }; use platform_auth::{ - AccessTokenClaims, AuthProvider, BindingStatus, RuntimeGuestTokenClaims, - RuntimeGuestTokenClaimsInput, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY, read_refresh_session_token, + AccessTokenClaims, AuthProvider, BindingStatus, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY, + RuntimeGuestTokenClaims, RuntimeGuestTokenClaimsInput, read_refresh_session_token, sign_runtime_guest_token, verify_access_token, verify_runtime_guest_token, }; use serde_json::{Value, json}; diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index f3fb1933..4610df88 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -1792,9 +1792,8 @@ mod tests { "publishedAtMicros": 1_713_686_401_234_000i64 }); - let work = - map_work_summary_record(gallery_row, &request_context, "画廊作者".to_string()) - .expect("gallery summary should use provided author display name"); + let work = map_work_summary_record(gallery_row, &request_context, "画廊作者".to_string()) + .expect("gallery summary should use provided author display name"); assert_eq!(work.author_display_name, "画廊作者"); assert_eq!( diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 166225d1..ab0e06fc 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -148,7 +148,8 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes description: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string(), cover_image_src: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC .to_string(), - prize_pool_mud_points: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS, + prize_pool_mud_points: + module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS, starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(), ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(), }, @@ -269,7 +270,10 @@ mod tests { assert_eq!(baby_object_match.badge, "\u{53ef}\u{521b}\u{5efa}"); assert_eq!(baby_object_match.sort_order, 90); assert_eq!(baby_object_match.category_id, "character"); - assert_eq!(baby_object_match.category_label, "\u{89d2}\u{8272}\u{521b}\u{4f5c}"); + assert_eq!( + baby_object_match.category_label, + "\u{89d2}\u{8272}\u{521b}\u{4f5c}" + ); assert_eq!(baby_object_match.category_sort_order, 40); } } diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index 9c531773..1d225034 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -1,6 +1,6 @@ use axum::http::StatusCode; -use platform_image::PlatformImageFailureAudit; use module_runtime::RuntimeTrackingScopeKind; +use platform_image::PlatformImageFailureAudit; use serde_json::{Value, json}; use time::OffsetDateTime; use uuid::Uuid; diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs index 18c1423b..cf2298bb 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -1,132 +1,26 @@ -#![allow(dead_code)] - -use std::{collections::BTreeMap, time::Duration}; - use axum::http::StatusCode; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use image::{GenericImageView, ImageFormat}; -use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest}; -use serde_json::json; +use platform_image::generated_asset_sheets as generated_asset_sheets_impl; use crate::{ http_error::AppError, openai_image_generation::DownloadedOpenAiImage, platform_errors::map_oss_error, state::AppState, }; -const GENERATED_ASSET_SHEET_PROVIDER: &str = "generated-asset-sheets"; -const GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000; -const GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 36; -const GENERATED_ASSET_SHEET_FOREGROUND_ALPHA_THRESHOLD: i32 = 36; -const GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE: f32 = 0.34; -const GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18; -const GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE: f32 = 0.82; +#[allow(unused_imports)] +pub(crate) use generated_asset_sheets_impl::{ + GeneratedAssetSheetError, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, + GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload, + apply_generated_asset_sheet_green_screen_alpha, +}; -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedAssetSheetPromptInput<'a> { - pub(crate) subject_text: &'a str, - pub(crate) item_names: &'a [String], - pub(crate) grid_size: usize, - pub(crate) item_name_prompt_template: Option<&'a str>, - pub(crate) special_prompt: Option<&'a str>, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedAssetSheetSliceImage { - pub(crate) bytes: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedAssetSheetUpload { - pub(crate) src: String, - pub(crate) object_key: String, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct GeneratedAssetSheetPersistPrompt { - pub(crate) sheet_prompt: Option, - pub(crate) item_name_prompt: Option, - pub(crate) special_prompt: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedAssetSheetPersistInput { - pub(crate) prefix: LegacyAssetPrefix, - pub(crate) owner_user_id: String, - pub(crate) session_id: String, - pub(crate) profile_id: String, - pub(crate) path_segments: Vec, - pub(crate) file_name: String, - pub(crate) content_type: String, - pub(crate) bytes: Vec, - pub(crate) asset_kind: String, - pub(crate) source_job_id: Option, - pub(crate) generated_at_micros: i64, - pub(crate) grid_size: usize, - pub(crate) row_index: usize, - pub(crate) view_index: usize, - pub(crate) prompt: GeneratedAssetSheetPersistPrompt, -} +#[cfg(test)] +pub(crate) use generated_asset_sheets_impl::crop_generated_asset_sheet_view_edge_matte; pub(crate) fn build_generated_asset_sheet_prompt( input: &GeneratedAssetSheetPromptInput<'_>, ) -> Result { - let grid_size = input.grid_size; - if grid_size == 0 { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集的 n 必须大于 0。", - })), - ); - } - if input.item_names.len() > grid_size { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集的物品行数不能超过 n。", - "gridSize": grid_size, - "itemCount": input.item_names.len(), - })), - ); - } - - let subject_text = input.subject_text.trim(); - let subject_text = if subject_text.is_empty() { - "系列素材" - } else { - subject_text - }; - let item_rows = input - .item_names - .iter() - .enumerate() - .map(|(index, item_name)| { - let row_index = index + 1; - let item_name = item_name.trim(); - if let Some(template) = input - .item_name_prompt_template - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return template - .replace("{row_index}", row_index.to_string().as_str()) - .replace("{item_name}", item_name) - .replace("{view_count}", grid_size.to_string().as_str()); - } - format!("第{row_index}行:{item_name} 的 {grid_size} 个不同视图") - }) - .collect::>() - .join(";"); - let special_prompt = input - .special_prompt - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("每个物品生成 {grid_size} 个不同视图。")); - - Ok(format!( - "生成一张1:1图片。固定生成{grid_size}行*{grid_size}列网格素材图,画面是{subject_text}。严格{grid_size}*{grid_size}均匀排布,严格按行组织:{item_rows}。{special_prompt}每个格子一个独立居中的完整素材,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若素材天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D素材。请让每个素材完整落在自己的格子中央,四周保留留白,相邻素材主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,素材主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。" - )) + generated_asset_sheets_impl::build_generated_asset_sheet_prompt(input) + .map_err(map_generated_asset_sheet_error) } pub(crate) fn slice_generated_asset_sheet( @@ -134,67 +28,8 @@ pub(crate) fn slice_generated_asset_sheet( item_names: &[String], grid_size: usize, ) -> Result>, AppError> { - if grid_size == 0 { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集的 n 必须大于 0。", - })), - ); - } - - let grid_size_u32 = u32::try_from(grid_size).map_err(|_| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集的 n 超出可支持范围。", - })) - })?; - let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": format!("系列素材图集解码失败:{error}"), - })) - })?; - let source = apply_generated_asset_sheet_green_screen_alpha(source); - let (width, height) = source.dimensions(); - let cell_width = width / grid_size_u32; - let cell_height = height / grid_size_u32; - if cell_width == 0 || cell_height == 0 { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集尺寸过小,无法切割。", - })), - ); - } - - let mut slices = Vec::with_capacity(item_names.len().min(grid_size)); - for item_index in 0..item_names.len().min(grid_size) { - let row = item_index as u32; - let mut views = Vec::with_capacity(grid_size); - for view_index in 0..grid_size { - let col = view_index as u32; - let (crop_x, crop_y, crop_width, crop_height) = - resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col); - let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); - let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); - let mut cursor = std::io::Cursor::new(Vec::new()); - cleaned - .write_to(&mut cursor, ImageFormat::Png) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": format!("系列素材图集切割失败:{error}"), - })) - })?; - views.push(GeneratedAssetSheetSliceImage { - bytes: cursor.into_inner(), - }); - } - slices.push(views); - } - - Ok(slices) + generated_asset_sheets_impl::slice_generated_asset_sheet(image, item_names, grid_size) + .map_err(map_generated_asset_sheet_error) } pub(crate) fn slice_generated_asset_sheet_two_items_per_row( @@ -203,1543 +38,56 @@ pub(crate) fn slice_generated_asset_sheet_two_items_per_row( grid_size: usize, views_per_item: usize, ) -> Result>, AppError> { - if grid_size == 0 || views_per_item == 0 { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集的 n 和每物品视图数必须大于 0。", - })), - ); - } - if !grid_size.is_multiple_of(views_per_item) { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集每行必须能均分为若干物品。", - "gridSize": grid_size, - "viewsPerItem": views_per_item, - })), - ); - } - - let grid_size_u32 = u32::try_from(grid_size).map_err(|_| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集的 n 超出可支持范围。", - })) - })?; - let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": format!("系列素材图集解码失败:{error}"), - })) - })?; - let source = apply_generated_asset_sheet_green_screen_alpha(source); - let (width, height) = source.dimensions(); - if width / grid_size_u32 == 0 || height / grid_size_u32 == 0 { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集尺寸过小,无法切割。", - })), - ); - } - - let items_per_row = grid_size / views_per_item; - let max_item_count = grid_size.saturating_mul(items_per_row); - let mut slices = Vec::with_capacity(item_names.len().min(max_item_count)); - for item_index in 0..item_names.len().min(max_item_count) { - let row = (item_index / items_per_row) as u32; - let start_col = ((item_index % items_per_row) * views_per_item) as u32; - let mut views = Vec::with_capacity(views_per_item); - for view_offset in 0..views_per_item { - let col = start_col + view_offset as u32; - let (crop_x, crop_y, crop_width, crop_height) = - resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col); - let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); - let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); - let mut cursor = std::io::Cursor::new(Vec::new()); - cleaned - .write_to(&mut cursor, ImageFormat::Png) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": format!("系列素材图集切割失败:{error}"), - })) - })?; - views.push(GeneratedAssetSheetSliceImage { - bytes: cursor.into_inner(), - }); - } - slices.push(views); - } - - Ok(slices) -} - -pub(crate) fn crop_generated_asset_sheet_view_edge_matte( - image: image::DynamicImage, -) -> image::DynamicImage { - let mut image = image.to_rgba8(); - let (width, height) = image.dimensions(); - remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize); - let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| { - GeneratedAssetSheetCellBounds { - x0: 0, - y0: 0, - x1: width, - y1: height, - } - }); - if bounds.x0 == 0 && bounds.y0 == 0 && bounds.x1 == width && bounds.y1 == height { - return image::DynamicImage::ImageRgba8(image); - } - - image::DynamicImage::ImageRgba8( - image::imageops::crop_imm( - &image, - bounds.x0, - bounds.y0, - bounds.width(), - bounds.height(), - ) - .to_image(), + generated_asset_sheets_impl::slice_generated_asset_sheet_two_items_per_row( + image, + item_names, + grid_size, + views_per_item, ) -} - -pub(crate) fn prepare_generated_asset_sheet_put_request( - input: GeneratedAssetSheetPersistInput, -) -> Result { - if input.grid_size == 0 { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集的 n 必须大于 0。", - })), - ); - } - if input.row_index == 0 - || input.view_index == 0 - || input.row_index > input.grid_size - || input.view_index > input.grid_size - { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": "系列素材图集持久化的行列索引必须落在 n*n 范围内。", - "gridSize": input.grid_size, - "rowIndex": input.row_index, - "viewIndex": input.view_index, - })), - ); - } - - let mut metadata = BTreeMap::new(); - metadata.insert( - "x-oss-meta-asset-kind".to_string(), - input.asset_kind.clone(), - ); - metadata.insert( - "x-oss-meta-owner-user-id".to_string(), - input.owner_user_id.clone(), - ); - metadata.insert( - "x-oss-meta-profile-id".to_string(), - input.profile_id.clone(), - ); - metadata.insert( - "x-oss-meta-generated-asset-sheet-grid-size".to_string(), - input.grid_size.to_string(), - ); - metadata.insert( - "x-oss-meta-generated-asset-sheet-row-index".to_string(), - input.row_index.to_string(), - ); - metadata.insert( - "x-oss-meta-generated-asset-sheet-view-index".to_string(), - input.view_index.to_string(), - ); - metadata.insert( - "x-oss-meta-generated-at-micros".to_string(), - input.generated_at_micros.to_string(), - ); - if let Some(source_job_id) = input - .source_job_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - metadata.insert( - "x-oss-meta-source-job-id".to_string(), - source_job_id.to_string(), - ); - } - insert_generated_asset_sheet_prompt_metadata( - &mut metadata, - "generated-asset-sheet-prompt-b64", - input.prompt.sheet_prompt.as_deref(), - ); - insert_generated_asset_sheet_prompt_metadata( - &mut metadata, - "generated-asset-sheet-item-name-prompt-b64", - input.prompt.item_name_prompt.as_deref(), - ); - insert_generated_asset_sheet_prompt_metadata( - &mut metadata, - "generated-asset-sheet-special-prompt-b64", - input.prompt.special_prompt.as_deref(), - ); - if input.prompt.sheet_prompt.is_some() - || input.prompt.item_name_prompt.is_some() - || input.prompt.special_prompt.is_some() - { - metadata.insert( - "x-oss-meta-generated-asset-sheet-prompt-encoding".to_string(), - "utf8-base64".to_string(), - ); - } - - Ok(OssPutObjectRequest { - prefix: input.prefix, - path_segments: std::iter::once(input.session_id.as_str()) - .chain(std::iter::once(input.profile_id.as_str())) - .chain(input.path_segments.iter().map(String::as_str)) - .map(|segment| sanitize_generated_asset_sheet_path_segment(segment, "asset")) - .collect(), - file_name: input.file_name, - content_type: Some(input.content_type), - access: OssObjectAccess::Private, - metadata, - body: input.bytes, - }) + .map_err(map_generated_asset_sheet_error) } pub(crate) async fn persist_generated_asset_sheet_bytes( state: &AppState, input: GeneratedAssetSheetPersistInput, ) -> Result { - let oss_client = state.oss_client().ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + let oss_client = require_oss_client(state)?; + generated_asset_sheets_impl::persist_generated_asset_sheet_bytes(oss_client, input) + .await + .map_err(map_generated_asset_sheet_error) +} + +fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { + state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(serde_json::json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) - })?; - let put_request = prepare_generated_asset_sheet_put_request(input)?; - let oss_http_client = reqwest::Client::builder() - .timeout(Duration::from_millis( - GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS, - )) - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": GENERATED_ASSET_SHEET_PROVIDER, - "message": format!("构造系列素材图集 OSS 上传客户端失败:{error}"), + }) +} + +fn map_generated_asset_sheet_error(error: GeneratedAssetSheetError) -> AppError { + match error { + GeneratedAssetSheetError::InvalidRequest { message } => { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(serde_json::json!({ + "provider": "generated-asset-sheets", + "message": message, })) - })?; - let put_result = oss_client - .put_object(&oss_http_client, put_request) - .await - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - - Ok(GeneratedAssetSheetUpload { - src: put_result.legacy_public_path, - object_key: put_result.object_key, - }) -} - -fn insert_generated_asset_sheet_prompt_metadata( - metadata: &mut BTreeMap, - key: &str, - value: Option<&str>, -) { - let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return; - }; - metadata.insert( - format!("x-oss-meta-{key}"), - BASE64_STANDARD.encode(value.as_bytes()), - ); -} - -#[derive(Clone, Copy, Debug)] -struct GeneratedAssetSheetCellBounds { - x0: u32, - y0: u32, - x1: u32, - y1: u32, -} - -impl GeneratedAssetSheetCellBounds { - fn width(self) -> u32 { - self.x1.saturating_sub(self.x0).max(1) - } - - fn height(self) -> u32 { - self.y1.saturating_sub(self.y0).max(1) - } - - fn area(self) -> u32 { - self.width().saturating_mul(self.height()) - } - - fn to_crop_tuple(self) -> (u32, u32, u32, u32) { - (self.x0, self.y0, self.width(), self.height()) - } -} - -fn resolve_generated_asset_sheet_cell_crop( - source: &image::DynamicImage, - grid_size: u32, - row: u32, - col: u32, -) -> (u32, u32, u32, u32) { - let (image_width, image_height) = source.dimensions(); - let cell = - resolve_generated_asset_sheet_cell_bounds(image_width, image_height, grid_size, row, col); - let Some(foreground) = detect_generated_asset_sheet_foreground_bounds(source, cell) else { - return cell.to_crop_tuple(); - }; - - let cell_width = cell.width(); - let cell_height = cell.height(); - let pad_x = (cell_width / 16).clamp(4, 16); - let pad_y = (cell_height / 16).clamp(4, 16); - let crop = GeneratedAssetSheetCellBounds { - x0: foreground.x0.saturating_sub(pad_x).max(cell.x0), - y0: foreground.y0.saturating_sub(pad_y).max(cell.y0), - x1: foreground.x1.saturating_add(pad_x).min(cell.x1), - y1: foreground.y1.saturating_add(pad_y).min(cell.y1), - }; - - crop.to_crop_tuple() -} - -fn resolve_generated_asset_sheet_cell_bounds( - image_width: u32, - image_height: u32, - grid_size: u32, - row: u32, - col: u32, -) -> GeneratedAssetSheetCellBounds { - let normalized_grid_size = grid_size.max(1); - let cell_x0 = col.saturating_mul(image_width) / normalized_grid_size; - let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / normalized_grid_size; - let cell_y0 = row.saturating_mul(image_height) / normalized_grid_size; - let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_grid_size; - - GeneratedAssetSheetCellBounds { - x0: cell_x0.min(image_width.saturating_sub(1)), - y0: cell_y0.min(image_height.saturating_sub(1)), - x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width), - y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height), - } -} - -fn detect_generated_asset_sheet_foreground_bounds( - source: &image::DynamicImage, - cell: GeneratedAssetSheetCellBounds, -) -> Option { - let background = sample_generated_asset_sheet_cell_background(source, cell); - let mut foreground: Option = None; - let mut foreground_pixels = 0u32; - - for y in cell.y0..cell.y1 { - for x in cell.x0..cell.x1 { - if !is_generated_asset_sheet_foreground_pixel(source.get_pixel(x, y).0, background) { - continue; - } - foreground_pixels = foreground_pixels.saturating_add(1); - foreground = Some(match foreground { - Some(bounds) => GeneratedAssetSheetCellBounds { - x0: bounds.x0.min(x), - y0: bounds.y0.min(y), - x1: bounds.x1.max(x.saturating_add(1)), - y1: bounds.y1.max(y.saturating_add(1)), - }, - None => GeneratedAssetSheetCellBounds { - x0: x, - y0: y, - x1: x.saturating_add(1), - y1: y.saturating_add(1), - }, - }); } - } - - let min_foreground_pixels = (cell.area() / 320).clamp(12, 220); - foreground.filter(|bounds| { - foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2 - }) -} - -fn detect_generated_asset_sheet_visible_bounds( - image: &image::RgbaImage, -) -> Option { - let (width, height) = image.dimensions(); - let mut bounds: Option = None; - let mut visible_pixels = 0u32; - - for y in 0..height { - for x in 0..width { - let pixel = image.get_pixel(x, y).0; - if !is_generated_asset_sheet_visible_pixel(pixel) { - continue; - } - visible_pixels = visible_pixels.saturating_add(1); - bounds = Some(match bounds { - Some(current) => GeneratedAssetSheetCellBounds { - x0: current.x0.min(x), - y0: current.y0.min(y), - x1: current.x1.max(x.saturating_add(1)), - y1: current.y1.max(y.saturating_add(1)), - }, - None => GeneratedAssetSheetCellBounds { - x0: x, - y0: y, - x1: x.saturating_add(1), - y1: y.saturating_add(1), - }, - }); + GeneratedAssetSheetError::DecodeImage { message } + | GeneratedAssetSheetError::EncodeImage { message } => { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(serde_json::json!({ + "provider": "generated-asset-sheets", + "message": message, + })) } - } - - let min_visible_pixels = ((width.saturating_mul(height)) / 540).clamp(10, 120); - bounds.filter(|visible_bounds| { - visible_pixels >= min_visible_pixels - && visible_bounds.width() > 2 - && visible_bounds.height() > 2 - }) -} - -fn sample_generated_asset_sheet_cell_background( - source: &image::DynamicImage, - cell: GeneratedAssetSheetCellBounds, -) -> [u8; 4] { - let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8); - let sample_points = [ - (cell.x0, cell.y0), - (cell.x1.saturating_sub(sample_size), cell.y0), - (cell.x0, cell.y1.saturating_sub(sample_size)), - ( - cell.x1.saturating_sub(sample_size), - cell.y1.saturating_sub(sample_size), - ), - ]; - let mut samples = Vec::new(); - for (start_x, start_y) in sample_points { - let mut totals = [0u32; 4]; - let mut count = 0u32; - for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) { - for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) { - let pixel = source.get_pixel(x, y).0; - totals[0] = totals[0].saturating_add(pixel[0] as u32); - totals[1] = totals[1].saturating_add(pixel[1] as u32); - totals[2] = totals[2].saturating_add(pixel[2] as u32); - totals[3] = totals[3].saturating_add(pixel[3] as u32); - count = count.saturating_add(1); - } - } - if count > 0 { - samples.push([ - (totals[0] / count) as u8, - (totals[1] / count) as u8, - (totals[2] / count) as u8, - (totals[3] / count) as u8, - ]); - } - } - - samples - .into_iter() - .min_by_key(|sample| { - let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16; - (sample[3] as u16, u16::MAX.saturating_sub(luminance)) - }) - .unwrap_or([255, 255, 255, 255]) -} - -fn clamp_generated_asset_sheet_unit(value: f32) -> f32 { - value.clamp(0.0, 1.0) -} - -fn lerp_generated_asset_sheet_channel(from: f32, to: f32, t: f32) -> f32 { - from + (to - from) * clamp_generated_asset_sheet_unit(t) -} - -fn is_generated_asset_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { - let alpha_diff = pixel[3] as i32 - background[3] as i32; - if alpha_diff.abs() >= GENERATED_ASSET_SHEET_FOREGROUND_ALPHA_THRESHOLD && pixel[3] > 24 { - return true; - } - if pixel[3] <= 24 { - return false; - } - - let color_diff = (pixel[0] as i32 - background[0] as i32).abs() - + (pixel[1] as i32 - background[1] as i32).abs() - + (pixel[2] as i32 - background[2] as i32).abs(); - color_diff >= GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD -} - -fn remove_generated_asset_sheet_view_edge_matte( - pixels: &mut [u8], - width: usize, - height: usize, -) -> bool { - let pixel_count = width.saturating_mul(height); - if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { - return false; - } - - let mut changed = false; - let mut background_mask = vec![0u8; pixel_count]; - let mut queue = Vec::::new(); - let mut queue_index = 0usize; - let mut transparent_pixel_count = 0usize; - for pixel_index in 0..pixel_count { - let offset = pixel_index * 4; - if pixels[offset + 3] == 0 { - background_mask[pixel_index] = 1; - queue.push(pixel_index); - transparent_pixel_count = transparent_pixel_count.saturating_add(1); - } - } - let has_transparent_background = transparent_pixel_count > pixel_count / 200; - - // 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘; - // 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte,避免误伤贴边主体。 - let edge_width = resolve_generated_asset_sheet_view_edge_cleanup_width(width, height); - for y in 0..height { - for x in 0..width { - if x >= edge_width - && y >= edge_width - && x.saturating_add(edge_width) < width - && y.saturating_add(edge_width) < height - { - continue; - } - let pixel_index = y * width + x; - if background_mask[pixel_index] != 0 { - continue; - } - let offset = pixel_index * 4; - let pixel = [ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]; - if !is_generated_asset_sheet_view_background_pixel(pixel) { - continue; - } - background_mask[pixel_index] = 1; - queue.push(pixel_index); - } - } - - while queue_index < queue.len() { - let pixel_index = queue[queue_index]; - queue_index += 1; - let x = pixel_index % width; - let y = pixel_index / width; - let neighbors = [ - (x > 0).then(|| pixel_index - 1), - (x + 1 < width).then_some(pixel_index + 1), - (y > 0).then(|| pixel_index - width), - (y + 1 < height).then_some(pixel_index + width), - ]; - - for next_pixel_index in neighbors.into_iter().flatten() { - if background_mask[next_pixel_index] != 0 { - continue; - } - let offset = next_pixel_index * 4; - let pixel = [ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]; - if !is_generated_asset_sheet_view_background_pixel(pixel) { - continue; - } - background_mask[next_pixel_index] = 1; - queue.push(next_pixel_index); - } - } - - for _ in 0..edge_width { - let mut expanded_mask = background_mask.clone(); - let mut changed_this_round = false; - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - if background_mask[pixel_index] != 0 { - continue; - } - let offset = pixel_index * 4; - if !is_generated_asset_sheet_view_background_pixel([ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]) { - continue; - } - - if touches_generated_asset_sheet_background_mask( - x, - y, - width, - height, - &background_mask, - ) { - expanded_mask[pixel_index] = 1; - changed_this_round = true; - } - } - } - background_mask = expanded_mask; - if !changed_this_round { - break; - } - } - - for pixel_index in 0..pixel_count { - if background_mask[pixel_index] == 0 { - continue; - } - let offset = pixel_index * 4; - if pixels[offset + 3] != 0 - || pixels[offset] != 0 - || pixels[offset + 1] != 0 - || pixels[offset + 2] != 0 - { - pixels[offset] = 0; - pixels[offset + 1] = 0; - pixels[offset + 2] = 0; - pixels[offset + 3] = 0; - changed = true; - } - } - - if has_transparent_background { - let mut visible_mask = vec![0u8; pixel_count]; - for pixel_index in 0..pixel_count { - let offset = pixel_index * 4; - if is_generated_asset_sheet_visible_pixel([ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]) { - visible_mask[pixel_index] = 1; - } - } - - for _ in 0..2 { - let mut changed_this_round = false; - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - if visible_mask[pixel_index] == 0 { - continue; - } - let offset = pixel_index * 4; - let pixel = [ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]; - if !is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) { - continue; - } - if !touches_generated_asset_sheet_background_mask( - x, - y, - width, - height, - &background_mask, - ) { - continue; - } - - if is_generated_asset_sheet_strong_green_contamination(pixel) { - pixels[offset] = 0; - pixels[offset + 1] = 0; - pixels[offset + 2] = 0; - pixels[offset + 3] = 0; - visible_mask[pixel_index] = 0; - background_mask[pixel_index] = 1; - changed = true; - changed_this_round = true; - continue; - } - - let replacement = collect_generated_asset_sheet_visible_neighbor_color( - pixels, - width, - height, - x, - y, - &background_mask, - &visible_mask, - ) - .unwrap_or(( - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - )); - let next_red = replacement.0.max(pixels[offset]); - let next_blue = replacement.2.max(pixels[offset + 2]); - let next_green = replacement - .1 - .min(next_red.max(next_blue).saturating_add(12)); - if next_red != pixels[offset] - || next_green != pixels[offset + 1] - || next_blue != pixels[offset + 2] - { - pixels[offset] = next_red; - pixels[offset + 1] = next_green; - pixels[offset + 2] = next_blue; - changed = true; - changed_this_round = true; - } - background_mask[pixel_index] = 1; - } - } - if !changed_this_round { - break; - } - } - } - - changed -} - -fn resolve_generated_asset_sheet_view_edge_cleanup_width(width: usize, height: usize) -> usize { - let min_side = width.min(height).max(1); - (min_side / 24).clamp(4, 12).min(min_side) -} - -fn is_generated_asset_sheet_view_background_pixel(pixel: [u8; 4]) -> bool { - pixel[3] < 16 - || is_generated_asset_sheet_soft_edge_pixel(pixel) - || compute_generated_asset_sheet_white_screen_score(pixel) > 0.18 -} - -fn is_generated_asset_sheet_visible_pixel(pixel: [u8; 4]) -> bool { - pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8) -} - -fn is_generated_asset_sheet_soft_edge_pixel(pixel: [u8; 4]) -> bool { - if pixel[3] == 0 { - return false; - } - - let red = pixel[0]; - let green = pixel[1]; - let blue = pixel[2]; - green >= 188 - && green.saturating_sub(red.max(blue)) >= 42 - && (red >= 48 || blue >= 96 || pixel[3] < 236) -} - -fn is_generated_asset_sheet_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool { - if pixel[3] == 0 { - return false; - } - - let red = pixel[0]; - let green = pixel[1]; - let blue = pixel[2]; - green >= 72 && green.saturating_sub(red.max(blue)) >= 18 -} - -fn is_generated_asset_sheet_strong_green_contamination(pixel: [u8; 4]) -> bool { - let red = pixel[0]; - let green = pixel[1]; - let blue = pixel[2]; - green >= 148 && green.saturating_sub(red.max(blue)) >= 34 -} - -fn collect_generated_asset_sheet_visible_neighbor_color( - pixels: &[u8], - width: usize, - height: usize, - x: usize, - y: usize, - background_mask: &[u8], - visible_mask: &[u8], -) -> Option<(u8, u8, u8)> { - let mut total_weight = 0.0f32; - let mut total_red = 0.0f32; - let mut total_green = 0.0f32; - let mut total_blue = 0.0f32; - - for offset_y in -3i32..=3 { - for offset_x in -3i32..=3 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { - continue; - } - - let next_pixel_index = next_y as usize * width + next_x as usize; - if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 { - continue; - } - - let next_offset = next_pixel_index * 4; - let next_alpha = pixels[next_offset + 3]; - if next_alpha < 96 { - continue; - } - let pixel = [ - pixels[next_offset], - pixels[next_offset + 1], - pixels[next_offset + 2], - next_alpha, - ]; - if is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) - || is_generated_asset_sheet_soft_edge_pixel(pixel) - { - continue; - } - - let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); - let weight = (next_alpha as f32 / 255.0) - * if distance <= 1 { - 2.0 - } else if distance <= 3 { - 1.2 - } else { - 0.7 - }; - total_weight += weight; - total_red += pixels[next_offset] as f32 * weight; - total_green += pixels[next_offset + 1] as f32 * weight; - total_blue += pixels[next_offset + 2] as f32 * weight; - } - } - - if total_weight <= 0.0 { - return None; - } - - Some(( - (total_red / total_weight).round() as u8, - (total_green / total_weight).round() as u8, - (total_blue / total_weight).round() as u8, - )) -} - -pub(crate) fn apply_generated_asset_sheet_green_screen_alpha( - source: image::DynamicImage, -) -> image::DynamicImage { - let mut image = source.to_rgba8(); - let (width, height) = image.dimensions(); - remove_generated_asset_sheet_green_screen_background( - image.as_mut(), - width as usize, - height as usize, - ); - image::DynamicImage::ImageRgba8(image) -} - -fn remove_generated_asset_sheet_green_screen_background( - pixels: &mut [u8], - width: usize, - height: usize, -) -> bool { - let pixel_count = width.saturating_mul(height); - if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { - return false; - } - - let mut green_scores = vec![0.0f32; pixel_count]; - let mut white_scores = vec![0.0f32; pixel_count]; - let mut background_hints = vec![0.0f32; pixel_count]; - let mut background_mask = vec![0u8; pixel_count]; - let mut queue = Vec::::new(); - let mut queue_index = 0usize; - - for pixel_index in 0..pixel_count { - let offset = pixel_index * 4; - let red = pixels[offset]; - let green = pixels[offset + 1]; - let blue = pixels[offset + 2]; - let alpha = pixels[offset + 3]; - let green_score = - compute_generated_asset_sheet_green_screen_score([red, green, blue, alpha]); - let white_score = - compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]); - let transparency_hint = - clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75; - - green_scores[pixel_index] = green_score; - white_scores[pixel_index] = white_score; - background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint); - } - - let seed_background_pixel = - |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { - if background_mask[pixel_index] != 0 { - return; - } - let alpha = pixels[pixel_index * 4 + 3]; - let strong_candidate = alpha < 40 - || green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 - && green_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) - || white_scores[pixel_index] > 0.32; - if !strong_candidate { - return; - } - background_mask[pixel_index] = 1; - queue.push(pixel_index); - }; - - for x in 0..width { - seed_background_pixel(x, &mut background_mask, &mut queue); - seed_background_pixel((height - 1) * width + x, &mut background_mask, &mut queue); - } - for y in 1..height.saturating_sub(1) { - seed_background_pixel(y * width, &mut background_mask, &mut queue); - seed_background_pixel(y * width + width - 1, &mut background_mask, &mut queue); - } - - while queue_index < queue.len() { - let pixel_index = queue[queue_index]; - queue_index += 1; - - let x = pixel_index % width; - let y = pixel_index / width; - let neighbor_indexes = [ - if x > 0 { Some(pixel_index - 1) } else { None }, - if x + 1 < width { - Some(pixel_index + 1) - } else { - None - }, - if y > 0 { - Some(pixel_index - width) - } else { - None - }, - if y + 1 < height { - Some(pixel_index + width) - } else { - None - }, - ]; - - for next_pixel_index in neighbor_indexes.into_iter().flatten() { - if background_mask[next_pixel_index] != 0 { - continue; - } - let next_offset = next_pixel_index * 4; - let alpha = pixels[next_offset + 3]; - let green_score = green_scores[next_pixel_index]; - let white_score = white_scores[next_pixel_index]; - let hint = background_hints[next_pixel_index]; - let reachable_soft_edge = hint > 0.08 - && alpha < 224 - && (green_score > 0.04 || white_score > 0.08 || alpha < 180); - let green_background = green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 && green_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); - if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge { - background_mask[next_pixel_index] = 1; - queue.push(next_pixel_index); - } - } - } - - for pixel_index in 0..pixel_count { - if background_mask[pixel_index] == 0 - && green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - { - background_mask[pixel_index] = 1; - } - } - - let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14); - for _ in 0..soft_green_cleanup_rounds { - let mut expanded_mask = background_mask.clone(); - let mut changed_this_round = false; - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - if background_mask[pixel_index] != 0 { - continue; - } - let offset = pixel_index * 4; - let pixel = [ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]; - let green_score = green_scores[pixel_index]; - let white_score = white_scores[pixel_index]; - if !is_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score) - { - continue; - } - if !touches_generated_asset_sheet_background_mask( - x, - y, - width, - height, - &background_mask, - ) { - continue; - } - - expanded_mask[pixel_index] = 1; - changed_this_round = true; - } - } - background_mask = expanded_mask; - if !changed_this_round { - break; - } - } - - for _ in 0..2 { - let mut expanded_mask = background_mask.clone(); - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - if background_mask[pixel_index] != 0 { - continue; - } - let alpha = pixels[pixel_index * 4 + 3]; - let green_score = green_scores[pixel_index]; - let white_score = white_scores[pixel_index]; - let hint = background_hints[pixel_index]; - let soft_matte_candidate = alpha < 224 - || white_score > 0.10 - || green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE; - if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate { - continue; - } - - let mut adjacent_background_count = 0usize; - for offset_y in -1i32..=1 { - for offset_x in -1i32..=1 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 - || next_x >= width as i32 - || next_y < 0 - || next_y >= height as i32 - { - adjacent_background_count += 1; - continue; - } - if background_mask[next_y as usize * width + next_x as usize] != 0 { - adjacent_background_count += 1; - } - } - } - - if adjacent_background_count >= 2 - || (adjacent_background_count >= 1 - && hint >= GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) - { - expanded_mask[pixel_index] = 1; - } - } - } - background_mask = expanded_mask; - } - - let mut changed = false; - for pixel_index in 0..pixel_count { - if background_mask[pixel_index] == 0 { - continue; - } - let alpha_offset = pixel_index * 4 + 3; - if pixels[alpha_offset] != 0 { - pixels[alpha_offset] = 0; - changed = true; - } - } - - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - let offset = pixel_index * 4; - let alpha = pixels[offset + 3]; - if alpha == 0 { - continue; - } - - let mut touches_transparent_edge = false; - for offset_y in -1i32..=1 { - for offset_x in -1i32..=1 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 - { - touches_transparent_edge = true; - continue; - } - let next_pixel_index = next_y as usize * width + next_x as usize; - if background_mask[next_pixel_index] != 0 - || pixels[next_pixel_index * 4 + 3] < 16 - { - touches_transparent_edge = true; - } - } - } - - if !touches_transparent_edge { - continue; - } - - let green_score = green_scores[pixel_index]; - let white_score = white_scores[pixel_index]; - let contamination = green_score.max(white_score).max(if alpha < 220 { - ((220 - alpha) as f32 / 220.0) * 0.25 - } else { - 0.0 - }); - if contamination < 0.06 { - continue; - } - - let sample = collect_generated_asset_sheet_foreground_neighbor_color( - pixels, - width, - height, - x, - y, - &background_mask, - &background_hints, - ); - let mut red = pixels[offset] as f32; - let mut green = pixels[offset + 1] as f32; - let mut blue = pixels[offset + 2] as f32; - let blend = clamp_generated_asset_sheet_unit(contamination.max(0.22)); - - if let Some((sample_red, sample_green, sample_blue)) = sample { - red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend); - green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend); - blue = lerp_generated_asset_sheet_channel(blue, sample_blue as f32, blend); - - if green_score > 0.04 { - green = green.min(sample_green as f32 + 18.0); - } - if white_score > 0.1 { - red = red.min(sample_red as f32 + 26.0); - green = green.min(sample_green as f32 + 26.0); - blue = blue.min(sample_blue as f32 + 26.0); - } - } else { - if green_score > 0.04 { - let toned_green = (green - (green - red.max(blue)) * 0.78) - .round() - .max(red.max(blue)); - green = green.min(toned_green).min(red.max(blue) + 18.0); - } - - if white_score > 0.12 { - let spread = red.max(green).max(blue) - red.min(green).min(blue); - if spread < 20.0 { - let toned_value = ((red + green + blue) / 3.0 * 0.88).round(); - red = red.min(toned_value); - green = green.min(toned_value); - blue = blue.min(toned_value); - } - } - } - - let mut next_alpha = alpha; - let edge_fade = (green_score * 0.35).max(white_score * 0.28); - if edge_fade > 0.08 { - next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; - if next_alpha < 10 { - next_alpha = 0; - } - } - - let next_red = red.round().clamp(0.0, 255.0) as u8; - let next_green = green.round().clamp(0.0, 255.0) as u8; - let next_blue = blue.round().clamp(0.0, 255.0) as u8; - if next_red != pixels[offset] - || next_green != pixels[offset + 1] - || next_blue != pixels[offset + 2] - || next_alpha != alpha - { - pixels[offset] = next_red; - pixels[offset + 1] = next_green; - pixels[offset + 2] = next_blue; - pixels[offset + 3] = next_alpha; - changed = true; - } - } - } - - changed -} - -fn touches_generated_asset_sheet_background_mask( - x: usize, - y: usize, - width: usize, - height: usize, - background_mask: &[u8], -) -> bool { - for offset_y in -1i32..=1 { - for offset_x in -1i32..=1 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { - return true; - } - if background_mask[next_y as usize * width + next_x as usize] != 0 { - return true; - } - } - } - false -} - -fn is_generated_asset_sheet_soft_green_matte_pixel( - pixel: [u8; 4], - green_score: f32, - white_score: f32, -) -> bool { - if pixel[3] == 0 || green_score < GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE { - return false; - } - - let red = pixel[0]; - let green = pixel[1]; - let blue = pixel[2]; - let foreground_mix = red.max(blue); - green >= 188 - && white_score < 0.34 - && green.saturating_sub(foreground_mix) >= 42 - && (red >= 48 || blue >= 96 || pixel[3] < 236) -} - -fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) -> f32 { - if pixel[3] == 0 { - return 1.0; - } - - let red = pixel[0] as f32; - let green = pixel[1] as f32; - let blue = pixel[2] as f32; - let green_lead = green - red.max(blue); - if green < 96.0 || green_lead <= 18.0 { - return 0.0; - } - - let green_ratio = green / (red + blue).max(1.0); - if green_ratio <= 0.9 { - return 0.0; - } - - (((green - 96.0) / 128.0).clamp(0.0, 1.0) * 0.34 - + ((green_lead - 18.0) / 120.0).clamp(0.0, 1.0) * 0.46 - + ((green_ratio - 0.9) / 2.4).clamp(0.0, 1.0) * 0.20) - .clamp(0.0, 1.0) -} - -fn compute_generated_asset_sheet_white_screen_score(pixel: [u8; 4]) -> f32 { - if pixel[3] == 0 { - return 1.0; - } - - let red = pixel[0] as f32; - let green = pixel[1] as f32; - let blue = pixel[2] as f32; - let max_channel = red.max(green).max(blue); - let min_channel = red.min(green).min(blue); - let average = (red + green + blue) / 3.0; - if average < 188.0 || min_channel < 168.0 { - return 0.0; - } - - let spread = max_channel - min_channel; - let neutrality = 1.0 - clamp_generated_asset_sheet_unit((spread - 6.0) / 34.0); - let brightness = clamp_generated_asset_sheet_unit((average - 188.0) / 55.0); - let floor = clamp_generated_asset_sheet_unit((min_channel - 168.0) / 60.0); - clamp_generated_asset_sheet_unit(neutrality * (brightness * 0.85 + floor * 0.15)) -} - -fn collect_generated_asset_sheet_foreground_neighbor_color( - pixels: &[u8], - width: usize, - height: usize, - x: usize, - y: usize, - background_mask: &[u8], - background_hints: &[f32], -) -> Option<(u8, u8, u8)> { - let mut total_weight = 0.0f32; - let mut total_red = 0.0f32; - let mut total_green = 0.0f32; - let mut total_blue = 0.0f32; - - for offset_y in -2i32..=2 { - for offset_x in -2i32..=2 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { - continue; - } - - let next_pixel_index = next_y as usize * width + next_x as usize; - if background_mask[next_pixel_index] != 0 || background_hints[next_pixel_index] >= 0.18 - { - continue; - } - - let next_offset = next_pixel_index * 4; - let next_alpha = pixels[next_offset + 3]; - if next_alpha < 96 { - continue; - } - let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); - let weight = (next_alpha as f32 / 255.0) - * if distance <= 1 { - 1.8 - } else if distance == 2 { - 1.2 - } else { - 0.7 - }; - - total_weight += weight; - total_red += pixels[next_offset] as f32 * weight; - total_green += pixels[next_offset + 1] as f32 * weight; - total_blue += pixels[next_offset + 2] as f32 * weight; - } - } - - if total_weight <= 0.0 { - return None; - } - - Some(( - (total_red / total_weight).round() as u8, - (total_green / total_weight).round() as u8, - (total_blue / total_weight).round() as u8, - )) -} - -fn sanitize_generated_asset_sheet_path_segment(raw: &str, fallback: &str) -> String { - let normalized = raw - .trim() - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::(); - let collapsed = normalized - .split('-') - .filter(|part| !part.is_empty()) - .collect::>() - .join("-"); - if collapsed.is_empty() { - fallback.to_string() - } else { - collapsed.chars().take(64).collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn build_test_image(width: u32, height: u32, color: [u8; 4]) -> image::RgbaImage { - image::RgbaImage::from_pixel(width, height, image::Rgba(color)) - } - - #[test] - fn generated_asset_sheet_prompt_uses_default_rows_and_special_instruction() { - let item_names = vec!["草莓".to_string(), "苹果".to_string()]; - - let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { - subject_text: "水果题材的抓大鹅 2D 物品素材", - item_names: &item_names, - grid_size: 5, - item_name_prompt_template: None, - special_prompt: None, - }) - .expect("prompt should build"); - - assert!(prompt.contains("5行*5列")); - assert!(prompt.contains("第1行:草莓 的 5 个不同视图")); - assert!(prompt.contains("第2行:苹果 的 5 个不同视图")); - assert!(prompt.contains("每个物品生成 5 个不同视图")); - } - - #[test] - fn generated_asset_sheet_prompt_allows_custom_row_template_and_special_prompt() { - let item_names = vec!["草莓".to_string()]; - - let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { - subject_text: "水果题材的抓大鹅 2D 物品素材", - item_names: &item_names, - grid_size: 5, - item_name_prompt_template: Some( - "第{row_index}行是 {item_name},共 {view_count} 个视图", - ), - special_prompt: Some("每个物品要生成五个不同视图:正面、左前、右前、俯视、背面。"), - }) - .expect("prompt should build"); - - assert!(prompt.contains("第1行是 草莓,共 5 个视图")); - assert!(prompt.contains("每个物品要生成五个不同视图")); - } - - #[test] - fn generated_asset_sheet_prompt_rejects_zero_grid_size() { - let item_names = vec!["草莓".to_string()]; - - let error = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { - subject_text: "水果题材的抓大鹅 2D 物品素材", - item_names: &item_names, - grid_size: 0, - item_name_prompt_template: None, - special_prompt: None, - }) - .expect_err("grid size 0 should be rejected"); - - assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); - } - - #[test] - fn generated_asset_sheet_slices_by_requested_grid_size() { - let width = 500; - let height = 500; - let item_names = vec!["樱桃".to_string(), "苹果".to_string()]; - let mut sheet = image::RgbaImage::new(width, height); - for row in 0..5 { - for col in 0..5 { - let color = image::Rgba([ - 32 + row as u8 * 40, - 24 + col as u8 * 36, - 210 - row as u8 * 30, - 255, - ]); - for y in row * 100..(row + 1) * 100 { - for x in col * 100..(col + 1) * 100 { - sheet.put_pixel(x, y, color); - } - } - } - } - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = - slice_generated_asset_sheet(&image, &item_names, 5).expect("sheet should slice"); - - assert_eq!(slices.len(), 2); - assert_eq!(slices[0].len(), 5); - assert_eq!(slices[1].len(), 5); - } - - #[test] - fn generated_asset_sheet_prepare_put_request_packs_prompt_metadata() { - let request = prepare_generated_asset_sheet_put_request(GeneratedAssetSheetPersistInput { - prefix: LegacyAssetPrefix::Match3DAssets, - owner_user_id: "user-1".to_string(), - session_id: "session-1".to_string(), - profile_id: "profile-1".to_string(), - path_segments: vec!["items".to_string(), "view".to_string()], - file_name: "view-01.png".to_string(), - content_type: "image/png".to_string(), - bytes: b"sheet-bytes".to_vec(), - asset_kind: "match3d_item_image_view".to_string(), - source_job_id: Some("task-1".to_string()), - generated_at_micros: 123, - grid_size: 5, - row_index: 1, - view_index: 2, - prompt: GeneratedAssetSheetPersistPrompt { - sheet_prompt: Some("sheet prompt".to_string()), - item_name_prompt: Some("item prompt".to_string()), - special_prompt: Some("special prompt".to_string()), - }, - }) - .expect("request should prepare"); - - assert_eq!( - request - .metadata - .get("x-oss-meta-generated-asset-sheet-prompt-encoding"), - Some(&"utf8-base64".to_string()) - ); - assert_eq!( - request - .metadata - .get("x-oss-meta-generated-asset-sheet-grid-size"), - Some(&"5".to_string()) - ); - assert_eq!( - request - .metadata - .get("x-oss-meta-generated-asset-sheet-row-index"), - Some(&"1".to_string()) - ); - assert_eq!( - request - .metadata - .get("x-oss-meta-generated-asset-sheet-view-index"), - Some(&"2".to_string()) - ); - assert_eq!( - request - .metadata - .get("x-oss-meta-generated-asset-sheet-prompt-b64"), - Some(&BASE64_STANDARD.encode("sheet prompt")) - ); - assert_eq!( - request - .metadata - .get("x-oss-meta-generated-asset-sheet-item-name-prompt-b64"), - Some(&BASE64_STANDARD.encode("item prompt")) - ); - assert_eq!( - request - .metadata - .get("x-oss-meta-generated-asset-sheet-special-prompt-b64"), - Some(&BASE64_STANDARD.encode("special prompt")) - ); + GeneratedAssetSheetError::BuildHttpClient { message } => AppError::from_status( + StatusCode::INTERNAL_SERVER_ERROR, + ) + .with_details(serde_json::json!({ + "provider": "generated-asset-sheets", + "message": message, + })), + GeneratedAssetSheetError::Oss(error) => map_oss_error(error, "aliyun-oss"), } } diff --git a/server-rs/crates/api-server/src/generated_image_assets.rs b/server-rs/crates/api-server/src/generated_image_assets.rs index ba1aad6d..5f4da592 100644 --- a/server-rs/crates/api-server/src/generated_image_assets.rs +++ b/server-rs/crates/api-server/src/generated_image_assets.rs @@ -1,13 +1,20 @@ -// 中文注释:C0 先落公共骨架,真实调用方迁移到 C1 后再移除未使用豁免。 -#![allow(dead_code, unused_imports)] +pub mod adapter { + pub use platform_image::generated_assets::adapter::*; +} -pub mod adapter; -pub mod helpers; +pub mod helpers { + pub use platform_image::generated_assets::helpers::*; +} -pub(crate) use adapter::{GeneratedImageAssetAdapter, GeneratedImageAssetAdapterBoundary}; -pub(crate) use helpers::{ - GeneratedImageAssetDataUrl, GeneratedImageAssetImageFormat, GeneratedImageAssetMetadataInput, - GeneratedImageAssetStoragePaths, build_generated_image_asset_metadata, - build_generated_image_asset_storage_paths, decode_generated_image_asset_data_url, - merge_generated_image_asset_metadata, normalize_generated_image_asset_mime, +pub(crate) use adapter::{ + GeneratedImageAssetAdapter, GeneratedImageAssetAdapterBoundary, + GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput, + GeneratedImageAssetPreparedPut, +}; +pub(crate) use helpers::{ + GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat, + GeneratedImageAssetMetadataInput, GeneratedImageAssetStoragePaths, + build_generated_image_asset_metadata, build_generated_image_asset_storage_paths, + decode_generated_image_asset_data_url, merge_generated_image_asset_metadata, + normalize_generated_image_asset_mime, }; diff --git a/server-rs/crates/api-server/src/hyper3d_generation.rs b/server-rs/crates/api-server/src/hyper3d_generation.rs index b5d8b691..ae81522a 100644 --- a/server-rs/crates/api-server/src/hyper3d_generation.rs +++ b/server-rs/crates/api-server/src/hyper3d_generation.rs @@ -1,13 +1,10 @@ -use std::time::Duration; - use axum::{ Json, extract::{State, rejection::JsonRejection}, http::StatusCode, response::Response, }; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use reqwest::{header, multipart}; +use platform_hyper3d::{Hyper3dError, Hyper3dSettings, Hyper3dStatusHint}; use serde_json::{Value, json}; use shared_contracts::hyper3d as contract; @@ -16,54 +13,20 @@ use crate::{ state::AppState, }; -const HYPER3D_PROVIDER: &str = "hyper3d-rodin"; -const RODIN_GEN2_TIER: &str = "Gen-2"; -const DEFAULT_GEOMETRY_FILE_FORMAT: &str = "glb"; -const DEFAULT_MATERIAL: &str = "PBR"; -const DEFAULT_QUALITY: &str = "medium"; -const DEFAULT_MESH_MODE: &str = "Quad"; -const DEFAULT_CONDITION_MODE: &str = "concat"; -const MAX_PROMPT_CHARS: usize = 2_000; -const MAX_NEGATIVE_PROMPT_CHARS: usize = 1_000; -const MAX_IMAGE_COUNT: usize = 5; -const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; - -#[derive(Clone, Debug)] -struct Hyper3dSettings { - base_url: String, - api_key: String, - request_timeout_ms: u64, -} - -#[derive(Clone, Debug)] -struct DecodedImageDataUrl { - bytes: Vec, - mime_type: String, - file_name: String, -} - -#[derive(Clone, Debug)] -struct SubmitOptions { - seed: Option, - geometry_file_format: String, - material: String, - quality: String, - mesh_mode: String, - addons: Vec, - bbox_condition: Option>, - preview_render: bool, -} - pub async fn submit_hyper3d_text_to_model( State(state): State, axum::extract::Extension(request_context): axum::extract::Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; - submit_text_to_model(&state, payload) + let settings = require_hyper3d_settings(&state) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + platform_hyper3d::submit_text_to_model(&settings, payload) .await .map(|payload| json_success_body(Some(&request_context), payload)) - .map_err(|error| error.into_response_with_context(Some(&request_context))) + .map_err(|error| { + map_platform_hyper3d_error(error).into_response_with_context(Some(&request_context)) + }) } pub async fn submit_hyper3d_image_to_model( @@ -72,10 +35,14 @@ pub async fn submit_hyper3d_image_to_model( payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; - submit_image_to_model(&state, payload) + let settings = require_hyper3d_settings(&state) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + platform_hyper3d::submit_image_to_model(&settings, payload) .await .map(|payload| json_success_body(Some(&request_context), payload)) - .map_err(|error| error.into_response_with_context(Some(&request_context))) + .map_err(|error| { + map_platform_hyper3d_error(error).into_response_with_context(Some(&request_context)) + }) } pub async fn get_hyper3d_task_status( @@ -84,10 +51,14 @@ pub async fn get_hyper3d_task_status( payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; - query_task_status(&state, payload) + let settings = require_hyper3d_settings(&state) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + platform_hyper3d::query_task_status(&settings, payload) .await .map(|payload| json_success_body(Some(&request_context), payload)) - .map_err(|error| error.into_response_with_context(Some(&request_context))) + .map_err(|error| { + map_platform_hyper3d_error(error).into_response_with_context(Some(&request_context)) + }) } pub async fn get_hyper3d_downloads( @@ -96,289 +67,23 @@ pub async fn get_hyper3d_downloads( payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = parse_json_payload(&request_context, payload)?; - query_downloads(&state, payload) + let settings = require_hyper3d_settings(&state) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + platform_hyper3d::query_downloads(&settings, payload) .await .map(|payload| json_success_body(Some(&request_context), payload)) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} - -async fn submit_text_to_model( - state: &AppState, - payload: contract::Hyper3dTextToModelRequest, -) -> Result { - let settings = require_hyper3d_settings(state)?; - let http_client = build_hyper3d_http_client(&settings)?; - let prompt = normalize_required_text(&payload.prompt, "prompt", MAX_PROMPT_CHARS)?; - let options = SubmitOptions::from_text_request(&payload)?; - let mut form = multipart::Form::new() - .text("tier", RODIN_GEN2_TIER.to_string()) - .text("prompt", prompt); - form = append_common_submit_fields(form, &options)?; - if let Some(negative_prompt) = normalize_optional_limited_text( - payload.negative_prompt.as_deref(), - MAX_NEGATIVE_PROMPT_CHARS, - )? { - form = form.text("negative_prompt", negative_prompt); - } - - let response = post_hyper3d_multipart( - &http_client, - &settings, - "/rodin", - form, - "提交 Hyper3D 文生模型任务失败", - ) - .await?; - - Ok(build_submit_response( - contract::Hyper3dGenerationMode::TextToModel, - response, - )?) -} - -pub(crate) async fn submit_image_to_model( - state: &AppState, - payload: contract::Hyper3dImageToModelRequest, -) -> Result { - let settings = require_hyper3d_settings(state)?; - let http_client = build_hyper3d_http_client(&settings)?; - let options = SubmitOptions::from_image_request(&payload)?; - let mut form = multipart::Form::new().text("tier", RODIN_GEN2_TIER.to_string()); - form = append_common_submit_fields(form, &options)?; - let condition_mode = normalize_enum( - payload.condition_mode.as_deref(), - DEFAULT_CONDITION_MODE, - &["concat", "fuse"], - "conditionMode", - )?; - form = form.text("condition_mode", condition_mode); - if let Some(prompt) = - normalize_optional_limited_text(payload.prompt.as_deref(), MAX_PROMPT_CHARS)? - { - form = form.text("prompt", prompt); - } - for image_url in payload - .image_urls - .iter() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - form = form.text("image_urls", image_url.to_string()); - } - for image in decode_image_data_urls(&payload.image_data_urls)? { - let part = multipart::Part::bytes(image.bytes) - .file_name(image.file_name) - .mime_str(&image.mime_type) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "message": format!("构造图生模型图片字段失败:{error}"), - })) - })?; - form = form.part("images", part); - } - - if payload.image_data_urls.is_empty() && payload.image_urls.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": "imageDataUrls", - "message": "图生模型至少需要一张参考图", - })), - ); - } - if payload.image_data_urls.len() + payload.image_urls.len() > MAX_IMAGE_COUNT { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": "imageDataUrls", - "message": format!("图生模型最多支持 {} 张参考图", MAX_IMAGE_COUNT), - })), - ); - } - - let response = post_hyper3d_multipart( - &http_client, - &settings, - "/rodin", - form, - "提交 Hyper3D 图生模型任务失败", - ) - .await?; - - Ok(build_submit_response( - contract::Hyper3dGenerationMode::ImageToModel, - response, - )?) -} - -pub(crate) async fn query_task_status( - state: &AppState, - payload: contract::Hyper3dTaskStatusRequest, -) -> Result { - let settings = require_hyper3d_settings(state)?; - let http_client = build_hyper3d_http_client(&settings)?; - // 中文注释:Hyper3D 返回的 subscriptionKey 是上游 opaque token,只做非空校验,不做人为 256 字符截断。 - let subscription_key = - normalize_required_opaque_text(&payload.subscription_key, "subscriptionKey")?; - let response = post_hyper3d_json( - &http_client, - &settings, - "/status", - json!({ "subscription_key": subscription_key }), - "查询 Hyper3D 模型任务状态失败", - ) - .await?; - - let jobs = extract_job_statuses(&response); - let status = resolve_hyper3d_overall_status(&response, &jobs); - - Ok(contract::Hyper3dTaskStatusResponse { - ok: true, - provider: HYPER3D_PROVIDER.to_string(), - status, - jobs, - raw: response, - }) -} - -pub(crate) async fn query_downloads( - state: &AppState, - payload: contract::Hyper3dDownloadRequest, -) -> Result { - let settings = require_hyper3d_settings(state)?; - let http_client = build_hyper3d_http_client(&settings)?; - let task_uuid = normalize_required_text(&payload.task_uuid, "taskUuid", 256)?; - let response = post_hyper3d_json( - &http_client, - &settings, - "/download", - json!({ "task_uuid": task_uuid }), - "获取 Hyper3D 模型下载列表失败", - ) - .await?; - - Ok(contract::Hyper3dDownloadResponse { - ok: true, - provider: HYPER3D_PROVIDER.to_string(), - files: extract_download_files(&response), - raw: response, - }) -} - -impl SubmitOptions { - fn from_text_request(payload: &contract::Hyper3dTextToModelRequest) -> Result { - Self::new( - payload.seed, - payload.geometry_file_format.as_deref(), - payload.material.as_deref(), - payload.quality.as_deref(), - payload.mesh_mode.as_deref(), - payload.addons.clone(), - payload.bbox_condition.clone(), - payload.preview_render, - ) - } - - fn from_image_request( - payload: &contract::Hyper3dImageToModelRequest, - ) -> Result { - Self::new( - payload.seed, - payload.geometry_file_format.as_deref(), - payload.material.as_deref(), - payload.quality.as_deref(), - payload.mesh_mode.as_deref(), - payload.addons.clone(), - payload.bbox_condition.clone(), - payload.preview_render, - ) - } - - #[allow(clippy::too_many_arguments)] - fn new( - seed: Option, - geometry_file_format: Option<&str>, - material: Option<&str>, - quality: Option<&str>, - mesh_mode: Option<&str>, - addons: Vec, - bbox_condition: Option>, - preview_render: Option, - ) -> Result { - Ok(Self { - seed, - geometry_file_format: normalize_enum( - geometry_file_format, - DEFAULT_GEOMETRY_FILE_FORMAT, - &["glb", "usdz", "fbx", "obj", "stl"], - "geometryFileFormat", - )?, - material: normalize_enum( - material, - DEFAULT_MATERIAL, - &["PBR", "Shaded", "All"], - "material", - )?, - quality: normalize_enum( - quality, - DEFAULT_QUALITY, - &["high", "medium", "low", "extra-low"], - "quality", - )?, - mesh_mode: normalize_enum(mesh_mode, DEFAULT_MESH_MODE, &["Quad", "Raw"], "meshMode")?, - addons: normalize_addons(addons)?, - bbox_condition: normalize_bbox_condition(bbox_condition)?, - preview_render: preview_render.unwrap_or(true), + .map_err(|error| { + map_platform_hyper3d_error(error).into_response_with_context(Some(&request_context)) }) - } -} - -fn append_common_submit_fields( - mut form: multipart::Form, - options: &SubmitOptions, -) -> Result { - form = form - .text( - "geometry_file_format", - options.geometry_file_format.to_string(), - ) - .text("material", options.material.to_string()) - .text("quality", options.quality.to_string()) - .text("mesh_mode", options.mesh_mode.to_string()) - .text("preview_render", options.preview_render.to_string()); - if let Some(seed) = options.seed { - form = form.text("seed", seed.to_string()); - } - for addon in &options.addons { - form = form.text("addons", addon.to_string()); - } - if let Some(bbox_condition) = &options.bbox_condition { - form = form.text( - "bbox_condition", - serde_json::to_string(bbox_condition).map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": "bboxCondition", - "message": format!("bboxCondition 序列化失败:{error}"), - })) - })?, - ); - } - - Ok(form) } fn require_hyper3d_settings(state: &AppState) -> Result { let base_url = state.config.hyper3d_base_url.trim().trim_end_matches('/'); if base_url.is_empty() { - return Err( - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "reason": "HYPER3D_BASE_URL 未配置", - "message": "Hyper3D Rodin 服务地址未配置,请设置 HYPER3D_BASE_URL 或 RODIN_BASE_URL 后重启 api-server。", - })), - ); + return Err(map_platform_hyper3d_error(Hyper3dError::invalid_config( + "HYPER3D_BASE_URL 未配置", + "Hyper3D Rodin 服务地址未配置,请设置 HYPER3D_BASE_URL 或 RODIN_BASE_URL 后重启 api-server。", + ))); } let api_key = state @@ -388,11 +93,10 @@ fn require_hyper3d_settings(state: &AppState) -> Result Result Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms)) - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "message": format!("构造 Hyper3D HTTP 客户端失败:{error}"), - })) - }) -} - -async fn post_hyper3d_multipart( - http_client: &reqwest::Client, - settings: &Hyper3dSettings, - path: &str, - form: multipart::Form, - failure_context: &str, -) -> Result { - let response = http_client - .post(format!("{}{}", settings.base_url, path)) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .multipart(form) - .send() - .await - .map_err(|error| hyper3d_bad_gateway(format!("{failure_context}:{error}")))?; - parse_hyper3d_response(response, failure_context).await -} - -async fn post_hyper3d_json( - http_client: &reqwest::Client, - settings: &Hyper3dSettings, - path: &str, - body: Value, - failure_context: &str, -) -> Result { - let response = http_client - .post(format!("{}{}", settings.base_url, path)) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .header(header::CONTENT_TYPE, "application/json") - .json(&body) - .send() - .await - .map_err(|error| hyper3d_bad_gateway(format!("{failure_context}:{error}")))?; - parse_hyper3d_response(response, failure_context).await -} - -async fn parse_hyper3d_response( - response: reqwest::Response, - failure_context: &str, -) -> Result { - let status = response.status(); - let raw_text = response.text().await.map_err(|error| { - hyper3d_bad_gateway(format!("{failure_context}:读取上游响应失败:{error}")) - })?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "message": parse_api_error_message(&raw_text, failure_context), - "status": status.as_u16(), - "rawExcerpt": truncate_raw(&raw_text), - })), - ); - } - - serde_json::from_str::(&raw_text).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "message": format!("{failure_context}:解析上游 JSON 失败:{error}"), - "rawExcerpt": truncate_raw(&raw_text), - })) - }) -} - -fn build_submit_response( - mode: contract::Hyper3dGenerationMode, - response: Value, -) -> Result { - let task_uuid = find_root_string_by_keys(&response, &["uuid", "task_uuid", "taskUuid"]) - .or_else(|| find_first_string_by_keys(&response, &["task_uuid", "taskUuid"])) - .ok_or_else(|| hyper3d_bad_gateway("Hyper3D 已响应,但未返回任务 uuid"))?; - let subscription_key = - find_root_string_by_keys(&response, &["subscription_key", "subscriptionKey"]) - .or_else(|| { - find_first_string_by_keys(&response, &["subscription_key", "subscriptionKey"]) - }) - .ok_or_else(|| hyper3d_bad_gateway("Hyper3D 已响应,但未返回 subscription_key"))?; - let job_uuids = extract_job_uuids(&response); - let message = find_first_string_by_keys(&response, &["message", "detail"]); - - Ok(contract::Hyper3dTaskSubmitResponse { - ok: true, - provider: HYPER3D_PROVIDER.to_string(), - mode, - task_uuid, - subscription_key, - job_uuids, - message, - tier: RODIN_GEN2_TIER.to_string(), - }) -} - -fn extract_job_statuses(payload: &Value) -> Vec { - let Some(array) = find_first_array_by_keys(payload, &["jobs", "tasks"]) else { - return Vec::new(); +fn map_platform_hyper3d_error(error: Hyper3dError) -> AppError { + let status = match error.status_hint() { + Hyper3dStatusHint::BadRequest => StatusCode::BAD_REQUEST, + Hyper3dStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE, + Hyper3dStatusHint::BadGateway => StatusCode::BAD_GATEWAY, + Hyper3dStatusHint::GatewayTimeout => StatusCode::GATEWAY_TIMEOUT, }; - - array - .iter() - .filter_map(|value| { - let status = find_first_string_by_keys(value, &["status", "state"]) - .map(|value| normalize_task_status(&value))?; - Some(contract::Hyper3dJobStatusPayload { - uuid: find_first_string_by_keys(value, &["uuid", "task_uuid", "taskUuid"]), - progress: find_first_f64_by_keys(value, &["progress", "percentage"]) - .map(|value| value as f32), - message: find_first_string_by_keys(value, &["message", "detail", "error"]), - status, - }) - }) - .collect() -} - -fn resolve_hyper3d_overall_status( - payload: &Value, - jobs: &[contract::Hyper3dJobStatusPayload], -) -> String { - if !jobs.is_empty() { - if jobs.iter().any(|job| job.status == "failed") { - return "failed".to_string(); + let mut details = json!({ + "provider": error.provider(), + "message": error.message(), + }); + match &error { + Hyper3dError::InvalidConfig { reason, .. } => { + details["reason"] = json!(reason); } - if jobs.iter().all(|job| job.status == "done") { - return "done".to_string(); + Hyper3dError::InvalidRequest { field, allowed, .. } => { + details["field"] = json!(field); + details["allowed"] = json!(allowed); } - if jobs.iter().any(|job| job.status == "generating") { - return "generating".to_string(); + Hyper3dError::Request { + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + .. + } => { + details["endpoint"] = json!(endpoint); + details["timeout"] = json!(timeout); + details["connect"] = json!(connect); + details["request"] = json!(request); + details["body"] = json!(body); + details["status"] = json!(status_code); + details["source"] = json!(source); } - if jobs.iter().any(|job| job.status == "waiting") { - return "waiting".to_string(); + Hyper3dError::Upstream { + upstream_status, + raw_excerpt, + .. + } => { + details["status"] = json!(upstream_status); + details["rawExcerpt"] = json!(raw_excerpt); } - return "unknown".to_string(); - } - - normalize_task_status( - find_first_string_by_key(payload, "status") - .as_deref() - .unwrap_or("unknown"), - ) -} - -fn extract_job_uuids(payload: &Value) -> Vec { - let mut job_uuids = Vec::new(); - if let Some(jobs) = payload.get("jobs") { - for uuid in collect_strings_by_keys(jobs, &["uuid", "task_uuid", "taskUuid", "uuids"]) { - if !job_uuids.contains(&uuid) { - job_uuids.push(uuid); - } + Hyper3dError::ResponseParse { raw_excerpt, .. } => { + details["rawExcerpt"] = json!(raw_excerpt); } + Hyper3dError::MissingField { .. } => {} } - for uuid in collect_strings_by_keys(payload, &["job_uuids", "jobUuids", "uuids"]) { - if !job_uuids.contains(&uuid) { - job_uuids.push(uuid); - } - } - job_uuids -} - -fn extract_download_files(payload: &Value) -> Vec { - let mut files = Vec::new(); - collect_download_files(payload, &mut files); - let mut deduped = Vec::new(); - for file in files { - if !deduped - .iter() - .any(|entry: &contract::Hyper3dDownloadFilePayload| entry.url == file.url) - { - deduped.push(file); - } - } - deduped -} - -fn collect_download_files(value: &Value, output: &mut Vec) { - match value { - Value::Object(object) => { - let maybe_url = object - .get("url") - .or_else(|| object.get("download_url")) - .or_else(|| object.get("downloadUrl")) - .or_else(|| object.get("file_url")) - .or_else(|| object.get("fileUrl")) - .or_else(|| object.get("signed_url")) - .or_else(|| object.get("signedUrl")) - .or_else(|| object.get("presigned_url")) - .or_else(|| object.get("presignedUrl")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| value.starts_with("http://") || value.starts_with("https://")); - if let Some(url) = maybe_url { - let name = object - .get("name") - .or_else(|| object.get("file_name")) - .or_else(|| object.get("filename")) - .or_else(|| object.get("fileName")) - .or_else(|| object.get("display_name")) - .or_else(|| object.get("displayName")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("model") - .to_string(); - output.push(contract::Hyper3dDownloadFilePayload { - name, - url: url.to_string(), - }); - } - for nested in object.values() { - collect_download_files(nested, output); - } - } - Value::Array(items) => { - for item in items { - collect_download_files(item, output); - } - } - _ => {} - } -} - -fn decode_image_data_urls(values: &[String]) -> Result, AppError> { - values - .iter() - .enumerate() - .map(|(index, value)| decode_image_data_url(value, index + 1)) - .collect() -} - -fn decode_image_data_url(value: &str, index: usize) -> Result { - let value = value.trim(); - let Some((metadata, encoded)) = value.split_once(',') else { - return Err(invalid_image_data_url("参考图必须是 data URL")); - }; - if !metadata.starts_with("data:image/") || !metadata.ends_with(";base64") { - return Err(invalid_image_data_url( - "参考图只支持 image/png、image/jpeg 或 image/webp 的 base64 data URL", - )); - } - let mime_type = metadata - .trim_start_matches("data:") - .trim_end_matches(";base64") - .to_string(); - let extension = match mime_type.as_str() { - "image/png" => "png", - "image/jpeg" | "image/jpg" => "jpg", - "image/webp" => "webp", - _ => { - return Err(invalid_image_data_url( - "参考图只支持 image/png、image/jpeg 或 image/webp", - )); - } - }; - let bytes = BASE64_STANDARD - .decode(encoded) - .map_err(|_| invalid_image_data_url("参考图 base64 解码失败"))?; - if bytes.is_empty() || bytes.len() > MAX_IMAGE_BYTES { - return Err(invalid_image_data_url("参考图为空或超过 10MB")); - } - - Ok(DecodedImageDataUrl { - bytes, - mime_type, - file_name: format!("reference-{index:02}.{extension}"), - }) -} - -fn invalid_image_data_url(message: &str) -> AppError { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": "imageDataUrls", - "message": message, - })) -} - -fn normalize_required_text( - value: &str, - field: &'static str, - max_chars: usize, -) -> Result { - let normalized = value.trim().to_string(); - if normalized.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": field, - "message": format!("{field} 不能为空"), - })), - ); - } - if normalized.chars().count() > max_chars { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": field, - "message": format!("{field} 超过 {} 字符", max_chars), - })), - ); - } - Ok(normalized) -} - -fn normalize_required_opaque_text(value: &str, field: &'static str) -> Result { - let normalized = value.trim().to_string(); - if normalized.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": field, - "message": format!("{field} 不能为空"), - })), - ); - } - - Ok(normalized) -} - -fn normalize_optional_limited_text( - value: Option<&str>, - max_chars: usize, -) -> Result, AppError> { - let Some(normalized) = value.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - if normalized.chars().count() > max_chars { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "message": format!("文本超过 {} 字符", max_chars), - })), - ); - } - Ok(Some(normalized.to_string())) -} - -fn normalize_enum( - value: Option<&str>, - default_value: &str, - allowed_values: &[&str], - field: &'static str, -) -> Result { - let value = value - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(default_value); - if let Some(allowed) = allowed_values - .iter() - .find(|allowed| allowed.eq_ignore_ascii_case(value)) - { - return Ok((*allowed).to_string()); - } - - Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": field, - "message": format!("{} 取值非法", field), - "allowed": allowed_values, - })), - ) -} - -fn normalize_addons(values: Vec) -> Result, AppError> { - let mut addons = Vec::new(); - for value in values { - let value = value.trim(); - if value.is_empty() { - continue; - } - if value != "HighPack" { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": "addons", - "message": "addons 首版只支持 HighPack", - })), - ); - } - if !addons.iter().any(|addon| addon == value) { - addons.push(value.to_string()); - } - } - Ok(addons) -} - -fn normalize_bbox_condition(value: Option>) -> Result>, AppError> { - let Some(value) = value else { - return Ok(None); - }; - if value.len() != 3 || value.iter().any(|item| !item.is_finite() || *item <= 0.0) { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "field": "bboxCondition", - "message": "bboxCondition 必须包含 3 个正数", - })), - ); - } - Ok(Some(value)) -} - -fn normalize_task_status(status: &str) -> String { - match status.trim().to_ascii_lowercase().as_str() { - "waiting" | "pending" | "queued" => "waiting".to_string(), - "generating" | "running" | "processing" => "generating".to_string(), - "done" | "finished" | "completed" | "success" | "succeeded" => "done".to_string(), - "failed" | "error" | "canceled" | "cancelled" => "failed".to_string(), - _ => "unknown".to_string(), - } -} - -fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { - if let Ok(parsed) = serde_json::from_str::(raw_text) { - for key in ["message", "detail", "error"] { - if let Some(message) = find_first_string_by_key(&parsed, key) - && !message.trim().is_empty() - { - return message; - } - } - } - raw_text - .trim() - .chars() - .take(240) - .collect::() - .trim() - .to_string() - .chars() - .next() - .map(|_| raw_text.trim().chars().take(240).collect()) - .unwrap_or_else(|| fallback_message.to_string()) -} - -fn find_first_array_by_keys<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Vec> { - match value { - Value::Object(object) => { - for (key, value) in object { - if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) - && let Some(array) = value.as_array() - { - return Some(array); - } - if let Some(found) = find_first_array_by_keys(value, keys) { - return Some(found); - } - } - None - } - Value::Array(items) => items - .iter() - .find_map(|item| find_first_array_by_keys(item, keys)), - _ => None, - } -} - -fn find_first_string_by_keys(value: &Value, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| find_first_string_by_key(value, key)) -} - -fn find_root_string_by_keys(value: &Value, keys: &[&str]) -> Option { - let object = value.as_object()?; - for key in keys { - if let Some(text) = object - .iter() - .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key)) - .and_then(|(_, value)| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return Some(text.to_string()); - } - } - None -} - -fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { - match value { - Value::Object(object) => { - for (key, value) in object { - if key.eq_ignore_ascii_case(target_key) - && let Some(text) = value.as_str() - { - return Some(text.trim().to_string()); - } - if let Some(found) = find_first_string_by_key(value, target_key) { - return Some(found); - } - } - None - } - Value::Array(items) => items - .iter() - .find_map(|item| find_first_string_by_key(item, target_key)), - _ => None, - } -} - -fn find_first_f64_by_keys(value: &Value, keys: &[&str]) -> Option { - match value { - Value::Object(object) => { - for (key, value) in object { - if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) - && let Some(number) = value.as_f64() - { - return Some(number); - } - if let Some(found) = find_first_f64_by_keys(value, keys) { - return Some(found); - } - } - None - } - Value::Array(items) => items - .iter() - .find_map(|item| find_first_f64_by_keys(item, keys)), - _ => None, - } -} - -fn collect_strings_by_keys(value: &Value, keys: &[&str]) -> Vec { - let mut results = Vec::new(); - collect_strings(value, keys, &mut results); - let mut deduped = Vec::new(); - for result in results { - if !deduped.contains(&result) { - deduped.push(result); - } - } - deduped -} - -fn collect_strings(value: &Value, keys: &[&str], output: &mut Vec) { - match value { - Value::Object(object) => { - for (key, value) in object { - if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) { - match value { - Value::String(text) if !text.trim().is_empty() => { - output.push(text.trim().to_string()); - } - Value::Array(items) => { - for item in items { - if let Some(text) = item.as_str().map(str::trim) - && !text.is_empty() - { - output.push(text.to_string()); - } - } - } - _ => {} - } - } - collect_strings(value, keys, output); - } - } - Value::Array(items) => { - for item in items { - collect_strings(item, keys, output); - } - } - _ => {} - } -} - -fn truncate_raw(raw_text: &str) -> String { - raw_text.chars().take(800).collect() -} - -fn hyper3d_bad_gateway(message: impl Into) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": HYPER3D_PROVIDER, - "message": message.into(), - })) + AppError::from_status(status).with_details(details) } fn parse_json_payload( @@ -1006,158 +169,3 @@ fn parse_json_payload( .into_response_with_context(Some(request_context)) }) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validates_and_defaults_submit_options() { - let payload = contract::Hyper3dTextToModelRequest { - prompt: "宝箱".to_string(), - negative_prompt: None, - seed: Some(7), - geometry_file_format: None, - material: None, - quality: None, - mesh_mode: None, - addons: vec!["HighPack".to_string()], - bbox_condition: Some(vec![1.0, 2.0, 3.0]), - preview_render: None, - }; - - let options = SubmitOptions::from_text_request(&payload).expect("options should build"); - - assert_eq!(options.geometry_file_format, "glb"); - assert_eq!(options.material, "PBR"); - assert_eq!(options.quality, "medium"); - assert_eq!(options.mesh_mode, "Quad"); - assert_eq!(options.addons, vec!["HighPack"]); - assert!(options.preview_render); - } - - #[test] - fn rejects_invalid_bbox_condition() { - let error = normalize_bbox_condition(Some(vec![1.0, 0.0, 3.0])) - .expect_err("invalid bbox should fail"); - - assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); - } - - #[test] - fn accepts_opaque_subscription_key_without_length_cap() { - let long_key = "a".repeat(300); - let normalized = - normalize_required_opaque_text(&format!(" {long_key} "), "subscriptionKey") - .expect("subscription key should be accepted"); - - assert_eq!(normalized, long_key); - } - - #[test] - fn decodes_png_data_url() { - let data_url = format!( - "data:image/png;base64,{}", - BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") - ); - - let image = decode_image_data_url(&data_url, 1).expect("image should decode"); - - assert_eq!(image.mime_type, "image/png"); - assert_eq!(image.file_name, "reference-01.png"); - assert!(!image.bytes.is_empty()); - } - - #[test] - fn extracts_submit_response_from_nested_payload() { - let response = build_submit_response( - contract::Hyper3dGenerationMode::TextToModel, - json!({ - "uuid": "task-1", - "jobs": { - "uuids": ["job-1", "job-2"], - "subscription_key": "sub-1" - }, - "message": "submitted" - }), - ) - .expect("submit response should build"); - - assert_eq!(response.task_uuid, "task-1"); - assert_eq!(response.subscription_key, "sub-1"); - assert_eq!(response.job_uuids, vec!["job-1", "job-2"]); - } - - #[test] - fn extracts_download_files_from_list() { - let files = extract_download_files(&json!({ - "list": [ - { "name": "model.glb", "url": "https://cdn.example/model.glb" }, - { "name": "preview.png", "url": "https://cdn.example/preview.png" } - ] - })); - - assert_eq!(files.len(), 2); - assert_eq!(files[0].name, "model.glb"); - } - - #[test] - fn extracts_download_files_from_file_url_aliases() { - let files = extract_download_files(&json!({ - "result": { - "files": [ - { - "fileName": "rodin-result.glb", - "fileUrl": "https://cdn.example/rodin-result.glb?token=1" - }, - { - "displayName": "preview.png", - "signedUrl": "https://cdn.example/preview.png?token=1" - } - ] - } - })); - - assert_eq!(files.len(), 2); - assert_eq!(files[0].name, "rodin-result.glb"); - assert_eq!(files[0].url, "https://cdn.example/rodin-result.glb?token=1"); - } - - #[test] - fn normalizes_status_values() { - assert_eq!(normalize_task_status("Waiting"), "waiting"); - assert_eq!(normalize_task_status("Generating"), "generating"); - assert_eq!(normalize_task_status("Done"), "done"); - assert_eq!(normalize_task_status("Failed"), "failed"); - } - - #[test] - fn resolves_status_done_only_when_all_jobs_done() { - let jobs = extract_job_statuses(&json!({ - "jobs": [ - { "uuid": "preview", "status": "Done" }, - { "uuid": "model", "status": "Generating" } - ] - })); - - assert_eq!( - resolve_hyper3d_overall_status(&json!({ "status": "Done" }), &jobs), - "generating" - ); - } - - #[test] - fn resolves_status_failed_when_any_job_failed() { - let jobs = extract_job_statuses(&json!({ - "jobs": [ - { "uuid": "preview", "status": "Done" }, - { "uuid": "model", "status": "Failed", "message": "bad input" } - ] - })); - - assert_eq!( - resolve_hyper3d_overall_status(&json!({ "status": "Generating" }), &jobs), - "failed" - ); - } -} diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 32222b53..30a71516 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -20,12 +20,14 @@ use shared_contracts::jump_hop::{ }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; -use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}}; +use std::{ + collections::BTreeMap, + time::{SystemTime, UNIX_EPOCH}, +}; use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, RuntimePrincipal}, - http_error::AppError, generated_asset_sheets::{ GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt, slice_generated_asset_sheet, @@ -35,16 +37,18 @@ use crate::{ adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, normalize_generated_image_asset_mime, }, + http_error::AppError, openai_image_generation::{ build_openai_image_http_client, create_openai_image_generation, require_openai_image_settings, }, request_context::RequestContext, state::AppState, - work_play_tracking::{record_work_play_start_after_success, WorkPlayTrackingDraft}, + work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; -const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = ["start", "normal", "target", "finish", "bonus", "accent"]; +const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = + ["start", "normal", "target", "finish", "bonus", "accent"]; const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; @@ -384,7 +388,10 @@ async fn maybe_generate_jump_hop_assets( } if payload.character_asset.is_some() && payload.tile_atlas_asset.is_some() - && payload.tile_assets.as_ref().is_some_and(|assets| !assets.is_empty()) + && payload + .tile_assets + .as_ref() + .is_some_and(|assets| !assets.is_empty()) { return Ok(()); } @@ -397,19 +404,18 @@ async fn maybe_generate_jump_hop_assets( .unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-")); payload.profile_id = Some(profile_id.clone()); - let settings = require_openai_image_settings(state) - .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; - let http_client = build_openai_image_http_client(&settings) - .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; + let settings = require_openai_image_settings(state).map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let http_client = build_openai_image_http_client(&settings).map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; let character_prompt = payload .character_prompt .as_deref() .unwrap_or("俯视角可爱主角,透明背景"); - let tile_prompt = payload - .tile_prompt - .as_deref() - .unwrap_or("等距立体地块图集"); + let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集"); let character_generated = create_openai_image_generation( &http_client, @@ -423,16 +429,20 @@ async fn maybe_generate_jump_hop_assets( ) .await .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; - let character_image = character_generated.images.into_iter().next().ok_or_else(|| { - jump_hop_error_response( - request_context, - JUMP_HOP_CREATION_PROVIDER, - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "跳一跳角色资产生成成功但未返回图片。", - })), - ) - })?; + let character_image = character_generated + .images + .into_iter() + .next() + .ok_or_else(|| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "跳一跳角色资产生成成功但未返回图片。", + })), + ) + })?; let character_asset = persist_jump_hop_generated_image_asset( state, owner_user_id, @@ -449,7 +459,14 @@ async fn maybe_generate_jump_hop_assets( let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { subject_text: tile_prompt, - item_names: &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()], + item_names: &vec![ + "start".to_string(), + "normal".to_string(), + "target".to_string(), + "finish".to_string(), + "bonus".to_string(), + "accent".to_string(), + ], grid_size: 3, item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"), special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"), @@ -479,7 +496,14 @@ async fn maybe_generate_jump_hop_assets( })?; let tile_slices = slice_generated_asset_sheet( &tile_image, - &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()], + &vec![ + "start".to_string(), + "normal".to_string(), + "target".to_string(), + "finish".to_string(), + "bonus".to_string(), + "accent".to_string(), + ], 3, ) .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; @@ -521,10 +545,11 @@ async fn maybe_generate_jump_hop_assets( payload.character_asset = Some(character_asset); payload.tile_atlas_asset = Some(tile_atlas_asset); payload.tile_assets = Some(tile_assets); - payload.cover_composite = payload - .cover_composite - .clone() - .or_else(|| Some(format!("/generated-jump-hop-assets/{profile_id}/cover-composite.png"))); + payload.cover_composite = payload.cover_composite.clone().or_else(|| { + Some(format!( + "/generated-jump-hop-assets/{profile_id}/cover-composite.png" + )) + }); Ok(()) } @@ -541,36 +566,37 @@ async fn persist_jump_hop_generated_image_asset( request_context: &RequestContext, ) -> Result { let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str()); - let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { - prefix, - path_segments: vec![profile_id.to_string(), slot.to_string()], - file_stem: "image".to_string(), - image: GeneratedImageAssetDataUrl { - format: image_format, - bytes: image.bytes, - }, - access: OssObjectAccess::Private, - metadata: GeneratedImageAssetAdapterMetadata { - asset_kind: Some(format!("jump-hop-{slot}")), - owner_user_id: Some(owner_user_id.to_string()), - entity_kind: Some("jump_hop_work".to_string()), - entity_id: Some(profile_id.to_string()), - slot: Some(slot.to_string()), - provider: Some("vector-engine".to_string()), - task_id: None, - }, - extra_metadata: BTreeMap::new(), - }) - .map_err(|error| { - jump_hop_error_response( - request_context, - JUMP_HOP_CREATION_PROVIDER, - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "generated-image-assets", - "message": format!("准备跳一跳图片资产上传请求失败:{error:?}"), - })), - ) - })?; + let prepared = + GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { + prefix, + path_segments: vec![profile_id.to_string(), slot.to_string()], + file_stem: "image".to_string(), + image: GeneratedImageAssetDataUrl { + format: image_format, + bytes: image.bytes, + }, + access: OssObjectAccess::Private, + metadata: GeneratedImageAssetAdapterMetadata { + asset_kind: Some(format!("jump-hop-{slot}")), + owner_user_id: Some(owner_user_id.to_string()), + entity_kind: Some("jump_hop_work".to_string()), + entity_id: Some(profile_id.to_string()), + slot: Some(slot.to_string()), + provider: Some("vector-engine".to_string()), + task_id: None, + }, + extra_metadata: BTreeMap::new(), + }) + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "generated-image-assets", + "message": format!("准备跳一跳图片资产上传请求失败:{error:?}"), + })), + ) + })?; let persisted_mime_type = prepared.format.mime_type.clone(); let oss_client = state.oss_client().ok_or_else(|| { jump_hop_error_response( @@ -709,7 +735,6 @@ fn build_jump_hop_work_play_tracking_draft( WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route) } - fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse { JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), diff --git a/server-rs/crates/api-server/src/modules/auth.rs b/server-rs/crates/api-server/src/modules/auth.rs index 54513715..784cdad2 100644 --- a/server-rs/crates/api-server/src/modules/auth.rs +++ b/server-rs/crates/api-server/src/modules/auth.rs @@ -65,7 +65,10 @@ pub fn router(state: AppState) -> Router { attach_refresh_session_token, )), ) - .route("/api/auth/runtime-guest-token", post(issue_runtime_guest_token)) + .route( + "/api/auth/runtime-guest-token", + post(issue_runtime_guest_token), + ) .route("/api/auth/phone/send-code", post(send_phone_code)) .route("/api/auth/phone/login", post(phone_login)) .route("/api/auth/wechat/start", get(start_wechat_login)) diff --git a/server-rs/crates/api-server/src/modules/bark_battle.rs b/server-rs/crates/api-server/src/modules/bark_battle.rs index 3cd4f6b8..14dac1ae 100644 --- a/server-rs/crates/api-server/src/modules/bark_battle.rs +++ b/server-rs/crates/api-server/src/modules/bark_battle.rs @@ -6,10 +6,10 @@ use axum::{ use crate::{ auth::require_bearer_auth, bark_battle::{ - create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run, - generate_bark_battle_image_asset, get_bark_battle_runtime_config, - list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work, - start_bark_battle_run, update_bark_battle_draft_config, + create_bark_battle_draft, finish_bark_battle_run, generate_bark_battle_image_asset, + get_bark_battle_run, get_bark_battle_runtime_config, list_bark_battle_gallery, + list_bark_battle_works, publish_bark_battle_work, start_bark_battle_run, + update_bark_battle_draft_config, }, state::AppState, }; diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 6b575f6a..d6bde919 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -490,13 +490,15 @@ pub(crate) async fn resolve_puzzle_reference_image( if let Some(parsed) = parse_puzzle_image_data_url(trimmed) { let bytes_len = parsed.bytes.len(); if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES { - return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "puzzle", - "field": "referenceImageSrc", - "message": build_puzzle_reference_image_too_large_message(bytes_len), - "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, - "actualBytes": bytes_len, - }))); + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": build_puzzle_reference_image_too_large_message(bytes_len), + "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, + "actualBytes": bytes_len, + })), + ); } return Ok(PuzzleResolvedReferenceImage { mime_type: parsed.mime_type, @@ -648,16 +650,18 @@ pub(crate) fn validate_puzzle_reference_asset_object( if asset_object.content_length == 0 || asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64 { - return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "asset-object", - "field": "referenceImageAssetObjectId", - "assetObjectId": asset_object.asset_object_id, - "message": build_puzzle_reference_image_too_large_message( - asset_object.content_length as usize, - ), - "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, - "actualBytes": asset_object.content_length, - }))); + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": build_puzzle_reference_image_too_large_message( + asset_object.content_length as usize, + ), + "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, + "actualBytes": asset_object.content_length, + })), + ); } if let Some(expected_owner_user_id) = owner_user_id .map(str::trim) diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs index e44f141e..c0072aef 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs @@ -1,1509 +1,24 @@ -use std::{collections::BTreeMap, time::Duration}; - -use axum::{ - Json, - extract::{Path, State, rejection::JsonRejection}, - http::StatusCode, - response::Response, -}; -use module_assets::{ - AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, - generate_asset_binding_id, generate_asset_object_id, -}; -use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest}; -use reqwest::header; -use serde_json::{Map, Value, json}; -use shared_contracts::{creation_audio, visual_novel as contract}; - -use crate::{ - api_response::json_success_body, asset_billing::execute_billable_asset_operation_with_cost, - auth::AuthenticatedAccessToken, http_error::AppError, platform_errors::map_oss_error, - request_context::RequestContext, state::AppState, -}; - -const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; -const VECTOR_ENGINE_SUNO_PROVIDER: &str = "vector-engine-suno"; -const VECTOR_ENGINE_VIDU_PROVIDER: &str = "vector-engine-vidu"; -const SUNO_DEFAULT_MODEL: &str = "chirp-v4"; -const VIDU_AUDIO_MODEL: &str = "audio1.0"; -const AUDIO_ENTITY_KIND: &str = "visual_novel_scene"; -const MUSIC_ASSET_KIND: &str = "visual_novel_music"; -const AMBIENT_SOUND_ASSET_KIND: &str = "visual_novel_ambient_sound"; -const MUSIC_SLOT: &str = "music"; -const AMBIENT_SOUND_SLOT: &str = "ambient_sound"; -const SUNO_PROMPT_MAX_CHARS: usize = 5_000; -const SUNO_TITLE_MAX_CHARS: usize = 80; -const SUNO_TAGS_MAX_CHARS: usize = 160; -const VIDU_PROMPT_MAX_CHARS: usize = 1_500; -const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5; -const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024; -const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5; -const CREATION_SOUND_EFFECT_POINTS_COST: u64 = 10; - -#[derive(Clone, Debug)] -struct VectorEngineAudioSettings { - base_url: String, - api_key: String, - request_timeout_ms: u64, -} - -#[derive(Clone, Debug)] -struct DownloadedAudio { - bytes: Vec, - mime_type: String, - extension: String, -} - -#[derive(Clone, Debug)] -struct AudioAssetBindingTarget { - entity_kind: String, - entity_id: String, - slot: String, - asset_kind: String, - profile_id: Option, - storage_prefix: LegacyAssetPrefix, - storage_scope: String, -} - -#[derive(Clone, Debug)] -pub(crate) struct GeneratedCreationAudioTarget { - pub entity_kind: String, - pub entity_id: String, - pub slot: String, - pub asset_kind: String, - pub profile_id: Option, - pub storage_prefix: LegacyAssetPrefix, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AudioAssetSlot { - BackgroundMusic, - SoundEffect, -} - -impl AudioAssetSlot { - fn provider(self) -> &'static str { - match self { - Self::BackgroundMusic => VECTOR_ENGINE_SUNO_PROVIDER, - Self::SoundEffect => VECTOR_ENGINE_VIDU_PROVIDER, - } - } - - fn asset_kind(self) -> &'static str { - match self { - Self::BackgroundMusic => MUSIC_ASSET_KIND, - Self::SoundEffect => AMBIENT_SOUND_ASSET_KIND, - } - } - - fn slot(self) -> &'static str { - match self { - Self::BackgroundMusic => MUSIC_SLOT, - Self::SoundEffect => AMBIENT_SOUND_SLOT, - } - } - - fn file_stem(self) -> &'static str { - match self { - Self::BackgroundMusic => "background-music", - Self::SoundEffect => "sound-effect", - } - } - - fn creation_contract_kind(self) -> creation_audio::CreationAudioGenerationKind { - match self { - Self::BackgroundMusic => creation_audio::CreationAudioGenerationKind::BackgroundMusic, - Self::SoundEffect => creation_audio::CreationAudioGenerationKind::SoundEffect, - } - } -} - -pub async fn create_visual_novel_background_music_task( - State(state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = parse_json_payload(&request_context, payload)?; - let settings = require_vector_engine_audio_settings(&state)?; - let http_client = build_vector_engine_audio_http_client(&settings)?; - let prompt = normalize_limited_text(&payload.prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?; - let title = normalize_limited_text(&payload.title, "title", SUNO_TITLE_MAX_CHARS)?; - let tags = payload - .tags - .as_deref() - .map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS)) - .transpose()?; - let model = normalize_optional_text(payload.model.as_deref()) - .unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string()); - - let mut body = Map::from_iter([ - ("prompt".to_string(), Value::String(prompt)), - ("mv".to_string(), Value::String(model)), - ("title".to_string(), Value::String(title)), - ("task".to_string(), Value::String("generate".to_string())), - ]); - if let Some(tags) = tags { - body.insert("tags".to_string(), Value::String(tags)); - } - - let response = post_vector_engine_json( - &http_client, - &settings, - "/suno/submit/music", - Value::Object(body), - "提交 Suno 背景音乐任务失败", - ) - .await?; - let task_id = extract_string_by_path(&response, &["data"]) - .or_else(|| find_first_string_by_key(&response, "task_id")) - .or_else(|| find_first_string_by_key(&response, "taskId")) - .ok_or_else(|| { - vector_engine_bad_gateway("提交 Suno 背景音乐任务失败:上游未返回任务 ID") - })?; - - Ok(json_success_body( - Some(&request_context), - contract::VisualNovelAudioGenerationTaskResponse { - kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic, - task_id, - provider: VECTOR_ENGINE_SUNO_PROVIDER.to_string(), - status: "submitted".to_string(), - }, - )) -} - -pub async fn create_background_music_task( - State(_state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let _ = parse_json_payload(&request_context, payload)?; - Err(creation_audio_generation_disabled_error() - .into_response_with_context(Some(&request_context))) -} - -pub async fn create_visual_novel_sound_effect_task( - State(state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = parse_json_payload(&request_context, payload)?; - let settings = require_vector_engine_audio_settings(&state)?; - let http_client = build_vector_engine_audio_http_client(&settings)?; - let prompt = normalize_limited_text(&payload.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?; - let duration = payload - .duration - .unwrap_or(DEFAULT_SOUND_EFFECT_DURATION_SECONDS) - .clamp(2, 10); - - let mut body = Map::from_iter([ - ( - "model".to_string(), - Value::String(VIDU_AUDIO_MODEL.to_string()), - ), - ("prompt".to_string(), Value::String(prompt)), - ("duration".to_string(), json!(duration)), - ]); - if let Some(seed) = payload.seed { - body.insert("seed".to_string(), json!(seed)); - } - - let response = post_vector_engine_json( - &http_client, - &settings, - "/ent/v2/text2audio", - Value::Object(body), - "提交 Vidu 音效任务失败", - ) - .await?; - let task_id = find_first_string_by_key(&response, "task_id") - .or_else(|| find_first_string_by_key(&response, "taskId")) - .ok_or_else(|| vector_engine_bad_gateway("提交 Vidu 音效任务失败:上游未返回任务 ID"))?; - let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into()); - - Ok(json_success_body( - Some(&request_context), - contract::VisualNovelAudioGenerationTaskResponse { - kind: contract::VisualNovelAudioGenerationKind::SoundEffect, - task_id, - provider: VECTOR_ENGINE_VIDU_PROVIDER.to_string(), - status, - }, - )) -} - -pub async fn create_sound_effect_task( - State(_state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let _ = parse_json_payload(&request_context, payload)?; - Err(creation_audio_generation_disabled_error() - .into_response_with_context(Some(&request_context))) -} - -pub(crate) async fn generate_sound_effect_asset_for_creation( - state: &AppState, - owner_user_id: &str, - prompt: String, - duration: Option, - seed: Option, - target: GeneratedCreationAudioTarget, -) -> Result { - let normalized_prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?; - let task = - create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?; - let target = AudioAssetBindingTarget { - storage_scope: target.entity_kind.clone(), - entity_kind: target.entity_kind, - entity_id: target.entity_id, - slot: target.slot, - asset_kind: target.asset_kind, - profile_id: target.profile_id, - storage_prefix: target.storage_prefix, - }; - let generated = wait_for_generated_audio_asset( - state, - owner_user_id, - task.task_id.clone(), - AudioAssetSlot::SoundEffect, - target, - ) - .await?; - let audio_src = generated - .audio_src - .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?; - - Ok(creation_audio::CreationAudioAsset { - task_id: generated.task_id, - provider: generated.provider, - asset_object_id: generated.asset_object_id, - asset_kind: generated.asset_kind, - audio_src, - prompt: Some(normalized_prompt), - title: None, - updated_at: Some(current_utc_iso_text()), - }) -} - -pub(crate) async fn generate_background_music_asset_for_creation( - state: &AppState, - owner_user_id: &str, - prompt: String, - title: String, - tags: Option, - model: Option, - target: GeneratedCreationAudioTarget, -) -> Result { - let normalized_prompt = - normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?; - let normalized_title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?; - let task = create_background_music_task_response( - state, - normalized_prompt.clone(), - normalized_title.clone(), - tags, - model, - ) - .await?; - let target = AudioAssetBindingTarget { - storage_scope: target.entity_kind.clone(), - entity_kind: target.entity_kind, - entity_id: target.entity_id, - slot: target.slot, - asset_kind: target.asset_kind, - profile_id: target.profile_id, - storage_prefix: target.storage_prefix, - }; - let generated = wait_for_generated_audio_asset( - state, - owner_user_id, - task.task_id.clone(), - AudioAssetSlot::BackgroundMusic, - target, - ) - .await?; - let audio_src = generated - .audio_src - .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?; - - Ok(creation_audio::CreationAudioAsset { - task_id: generated.task_id, - provider: generated.provider, - asset_object_id: generated.asset_object_id, - asset_kind: generated.asset_kind, - audio_src, - prompt: Some(normalized_prompt), - title: Some(normalized_title), - updated_at: Some(current_utc_iso_text()), - }) -} - -async fn create_background_music_task_response( - state: &AppState, - prompt: String, - title: String, - tags: Option, - model: Option, -) -> Result { - let settings = require_vector_engine_audio_settings(state)?; - let http_client = build_vector_engine_audio_http_client(&settings)?; - let prompt = normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?; - let title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?; - let tags = tags - .as_deref() - .map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS)) - .transpose()?; - let model = - normalize_optional_text(model.as_deref()).unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string()); - - let mut body = Map::from_iter([ - ("prompt".to_string(), Value::String(prompt)), - ("mv".to_string(), Value::String(model)), - ("title".to_string(), Value::String(title)), - ("task".to_string(), Value::String("generate".to_string())), - ("make_instrumental".to_string(), Value::Bool(true)), - ]); - if let Some(tags) = tags { - body.insert("tags".to_string(), Value::String(tags)); - } - - let response = post_vector_engine_json( - &http_client, - &settings, - "/suno/submit/music", - Value::Object(body), - "提交 Suno 背景音乐任务失败", - ) - .await?; - let task_id = extract_string_by_path(&response, &["data"]) - .or_else(|| find_first_string_by_key(&response, "task_id")) - .or_else(|| find_first_string_by_key(&response, "taskId")) - .ok_or_else(|| { - vector_engine_bad_gateway("提交 Suno 背景音乐任务失败:上游未返回任务 ID") - })?; - - Ok(creation_audio::AudioGenerationTaskResponse { - kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic, - task_id, - provider: VECTOR_ENGINE_SUNO_PROVIDER.to_string(), - status: "submitted".to_string(), - }) -} - -async fn create_sound_effect_task_response( - state: &AppState, - prompt: String, - duration: Option, - seed: Option, -) -> Result { - let settings = require_vector_engine_audio_settings(state)?; - let http_client = build_vector_engine_audio_http_client(&settings)?; - let prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?; - let duration = duration - .unwrap_or(DEFAULT_SOUND_EFFECT_DURATION_SECONDS) - .clamp(2, 10); - - let mut body = Map::from_iter([ - ( - "model".to_string(), - Value::String(VIDU_AUDIO_MODEL.to_string()), - ), - ("prompt".to_string(), Value::String(prompt)), - ("duration".to_string(), json!(duration)), - ]); - if let Some(seed) = seed { - body.insert("seed".to_string(), json!(seed)); - } - - let response = post_vector_engine_json( - &http_client, - &settings, - "/ent/v2/text2audio", - Value::Object(body), - "提交 Vidu 音效任务失败", - ) - .await?; - let task_id = find_first_string_by_key(&response, "task_id") - .or_else(|| find_first_string_by_key(&response, "taskId")) - .ok_or_else(|| vector_engine_bad_gateway("提交 Vidu 音效任务失败:上游未返回任务 ID"))?; - let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into()); - - Ok(creation_audio::AudioGenerationTaskResponse { - kind: creation_audio::CreationAudioGenerationKind::SoundEffect, - task_id, - provider: VECTOR_ENGINE_VIDU_PROVIDER.to_string(), - status, - }) -} - -pub async fn publish_visual_novel_background_music_asset( - State(state): State, - Path(task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - let target = build_visual_novel_audio_target(payload, AudioAssetSlot::BackgroundMusic)?; - publish_generated_audio_asset( - &state, - authenticated.claims().user_id(), - task_id, - AudioAssetSlot::BackgroundMusic, - target, - ) - .await - .map(|payload| { - json_success_body( - Some(&request_context), - contract::VisualNovelGeneratedAudioAssetResponse { - kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic, - task_id: payload.task_id, - provider: payload.provider, - status: payload.status, - asset_object_id: payload.asset_object_id, - asset_kind: payload.asset_kind, - audio_src: payload.audio_src, - }, - ) - }) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} - -pub async fn publish_visual_novel_sound_effect_asset( - State(state): State, - Path(task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - let target = build_visual_novel_audio_target(payload, AudioAssetSlot::SoundEffect)?; - publish_generated_audio_asset( - &state, - authenticated.claims().user_id(), - task_id, - AudioAssetSlot::SoundEffect, - target, - ) - .await - .map(|payload| { - json_success_body( - Some(&request_context), - contract::VisualNovelGeneratedAudioAssetResponse { - kind: contract::VisualNovelAudioGenerationKind::SoundEffect, - task_id: payload.task_id, - provider: payload.provider, - status: payload.status, - asset_object_id: payload.asset_object_id, - asset_kind: payload.asset_kind, - audio_src: payload.audio_src, - }, - ) - }) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} - -pub async fn publish_background_music_asset( - State(_state): State, - Path(_task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(_authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - Err(creation_audio_generation_disabled_error_for_target(payload) - .into_response_with_context(Some(&request_context))) -} - -pub async fn publish_sound_effect_asset( - State(state): State, - Path(task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect) - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - publish_generated_audio_asset( - &state, - authenticated.claims().user_id(), - task_id, - AudioAssetSlot::SoundEffect, - target, - ) - .await - .map(|payload| json_success_body(Some(&request_context), payload)) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} - -async fn publish_generated_audio_asset( - state: &AppState, - owner_user_id: &str, - task_id: String, - slot: AudioAssetSlot, - target: AudioAssetBindingTarget, -) -> Result { - let task_id = normalize_limited_text(&task_id, "taskId", 160)?; - let settings = require_vector_engine_audio_settings(state)?; - let http_client = build_vector_engine_audio_http_client(&settings)?; - let task_payload = fetch_audio_task_payload(&http_client, &settings, slot, &task_id).await?; - let status = normalize_task_status( - find_first_string_by_key(&task_payload, "status") - .or_else(|| find_first_string_by_key(&task_payload, "state")) - .or_else(|| find_first_string_by_key(&task_payload, "Status")) - .as_deref() - .unwrap_or(""), - ); - - let mut audio_urls = extract_audio_urls(&task_payload); - if slot == AudioAssetSlot::BackgroundMusic && audio_urls.is_empty() { - if let Some(clip_id) = extract_string_by_path(&task_payload, &["data"]) - .filter(|value| !value.trim().is_empty()) - { - let wav_payload = get_vector_engine_json( - &http_client, - &settings, - &format!("/suno/act/wav/{}", encode_path_segment(clip_id.as_str())), - "获取 Suno wav 音频失败", - ) - .await?; - audio_urls = extract_audio_urls(&wav_payload); - } - } - - if is_pending_task_status(&status) && audio_urls.is_empty() { - return Ok(creation_audio::GeneratedAudioAssetResponse { - kind: slot.creation_contract_kind(), - task_id, - provider: slot.provider().to_string(), - status, - asset_object_id: None, - asset_kind: None, - audio_src: None, - }); - } - - if is_failed_task_status(&status) { - return Err(vector_engine_bad_gateway( - "音频生成任务失败,请调整提示词后重试", - )); - } - - let audio_url = audio_urls - .into_iter() - .next() - .ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?; - let billing_asset_kind = target.asset_kind.clone(); - let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target); - let points_cost = resolve_creation_audio_points_cost(slot, &target); - let persisted = execute_billable_asset_operation_with_cost( - state, - owner_user_id, - billing_asset_kind.as_str(), - billing_asset_id.as_str(), - points_cost, - async { - let audio = download_generated_audio(&http_client, &audio_url, slot.provider()).await?; - persist_generated_audio_asset( - state, - &http_client, - owner_user_id, - &task_id, - slot, - target.clone(), - audio, - ) - .await - }, - ) - .await?; - - Ok(creation_audio::GeneratedAudioAssetResponse { - kind: slot.creation_contract_kind(), - task_id, - provider: slot.provider().to_string(), - status: "completed".to_string(), - asset_object_id: Some(persisted.asset_object_id), - asset_kind: Some(target.asset_kind), - audio_src: Some(persisted.audio_src), - }) -} - -async fn wait_for_generated_audio_asset( - state: &AppState, - owner_user_id: &str, - task_id: String, - slot: AudioAssetSlot, - target: AudioAssetBindingTarget, -) -> Result { - let mut latest_status = String::new(); - for _ in 0..40 { - let response = publish_generated_audio_asset( - state, - owner_user_id, - task_id.clone(), - slot, - target.clone(), - ) - .await?; - if response - .audio_src - .as_deref() - .map(str::trim) - .is_some_and(|value| !value.is_empty()) - { - return Ok(response); - } - latest_status = response.status; - tokio::time::sleep(Duration::from_millis(3_000)).await; - } - - Err(vector_engine_bad_gateway(format!( - "音频生成超时:{}", - if latest_status.trim().is_empty() { - task_id - } else { - latest_status - } - ))) -} - -fn build_audio_billing_asset_id( - task_id: &str, - slot: AudioAssetSlot, - target: &AudioAssetBindingTarget, -) -> String { - format!( - "creation-audio:{}:{}:{}:{}", - slot.file_stem(), - task_id, - target.entity_id, - target.slot - ) -} - -fn resolve_creation_audio_points_cost( - slot: AudioAssetSlot, - _target: &AudioAssetBindingTarget, -) -> u64 { - match slot { - AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST, - AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST, - } -} - -async fn fetch_audio_task_payload( - http_client: &reqwest::Client, - settings: &VectorEngineAudioSettings, - slot: AudioAssetSlot, - task_id: &str, -) -> Result { - match slot { - AudioAssetSlot::BackgroundMusic => { - get_vector_engine_json( - http_client, - settings, - &format!("/suno/fetch/{}", encode_path_segment(task_id)), - "查询 Suno 背景音乐任务失败", - ) - .await - } - AudioAssetSlot::SoundEffect => { - get_vector_engine_json( - http_client, - settings, - &format!("/ent/v2/tasks/{}/creations", encode_path_segment(task_id)), - "查询 Vidu 音效任务失败", - ) - .await - } - } -} - -#[derive(Clone, Debug)] -struct PersistedAudioAsset { - asset_object_id: String, - audio_src: String, -} - -async fn persist_generated_audio_asset( - state: &AppState, - http_client: &reqwest::Client, - owner_user_id: &str, - task_id: &str, - slot: AudioAssetSlot, - target: AudioAssetBindingTarget, - audio: DownloadedAudio, -) -> Result { - let oss_client = state.oss_client().ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "aliyun-oss", - "reason": "OSS 未完成环境变量配置", - })) - })?; - - let file_name = format!("{}-{}.{}", slot.file_stem(), task_id, audio.extension); - let put_result = oss_client - .put_object( - http_client, - OssPutObjectRequest { - prefix: target.storage_prefix, - path_segments: vec![ - target.storage_scope.clone(), - target - .profile_id - .clone() - .unwrap_or_else(|| "draft".to_string()), - target.entity_id.clone(), - target.slot.clone(), - ] - .into_iter() - .map(|segment| sanitize_audio_path_segment(segment.as_str(), "audio")) - .collect(), - file_name, - content_type: Some(audio.mime_type.clone()), - access: OssObjectAccess::Private, - metadata: build_audio_asset_metadata( - owner_user_id, - target.profile_id.as_deref(), - &target, - slot, - ), - body: audio.bytes, - }, - ) - .await - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let head = oss_client - .head_object( - http_client, - platform_oss::OssHeadObjectRequest { - object_key: put_result.object_key.clone(), - }, - ) - .await - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let now_micros = current_utc_micros(); - let asset_object = state - .spacetime_client() - .confirm_asset_object( - build_asset_object_upsert_input( - generate_asset_object_id(now_micros), - head.bucket, - head.object_key, - AssetObjectAccessPolicy::Private, - head.content_type.or(Some(audio.mime_type)), - head.content_length, - head.etag, - target.asset_kind.clone(), - Some(task_id.to_string()), - Some(owner_user_id.to_string()), - target.profile_id.clone(), - Some(target.entity_id.clone()), - now_micros, - ) - .map_err(map_asset_field_error)?, - ) - .await - .map_err(map_spacetime_error)?; - state - .spacetime_client() - .bind_asset_object_to_entity( - build_asset_entity_binding_input( - generate_asset_binding_id(now_micros), - asset_object.asset_object_id.clone(), - target.entity_kind, - target.entity_id, - target.slot, - target.asset_kind, - Some(owner_user_id.to_string()), - target.profile_id, - now_micros, - ) - .map_err(map_asset_field_error)?, - ) - .await - .map_err(map_spacetime_error)?; - - Ok(PersistedAudioAsset { - asset_object_id: asset_object.asset_object_id, - audio_src: put_result.legacy_public_path, - }) -} - -fn build_audio_asset_metadata( - owner_user_id: &str, - profile_id: Option<&str>, - target: &AudioAssetBindingTarget, - slot: AudioAssetSlot, -) -> BTreeMap { - let mut metadata = BTreeMap::from([ - ("asset-kind".to_string(), target.asset_kind.clone()), - ("owner-user-id".to_string(), owner_user_id.to_string()), - ("entity-kind".to_string(), target.entity_kind.clone()), - ("entity-id".to_string(), target.entity_id.clone()), - ("slot".to_string(), target.slot.clone()), - ("provider".to_string(), slot.provider().to_string()), - ]); - if let Some(profile_id) = profile_id { - metadata.insert("profile-id".to_string(), profile_id.to_string()); - } - metadata -} - -fn build_visual_novel_audio_target( - payload: contract::PublishVisualNovelGeneratedAudioAssetRequest, - slot: AudioAssetSlot, -) -> Result { - let entity_id = normalize_limited_text(&payload.scene_id, "sceneId", 160)?; - Ok(AudioAssetBindingTarget { - entity_kind: AUDIO_ENTITY_KIND.to_string(), - entity_id, - slot: slot.slot().to_string(), - asset_kind: slot.asset_kind().to_string(), - profile_id: normalize_optional_text(payload.profile_id.as_deref()), - storage_prefix: LegacyAssetPrefix::CustomWorldScenes, - storage_scope: "visual-novel".to_string(), - }) -} - -fn build_creation_audio_target( - payload: creation_audio::PublishGeneratedAudioAssetRequest, - _slot: AudioAssetSlot, -) -> Result { - Err(creation_audio_generation_disabled_error_for_target(payload)) -} - -fn creation_audio_generation_disabled_error() -> AppError { - AppError::from_status(StatusCode::GONE).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "当前创作音频目标未开放", - })) -} - -fn creation_audio_generation_disabled_error_for_target( - payload: creation_audio::PublishGeneratedAudioAssetRequest, -) -> AppError { - creation_audio_generation_disabled_error().with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "当前创作音频目标未开放", - "entityKind": payload.entity_kind.trim(), - "slot": payload.slot.trim(), - })) -} - -fn require_vector_engine_audio_settings( - state: &AppState, -) -> Result { - let base_url = state - .config - .vector_engine_base_url - .trim() - .trim_end_matches('/'); - if base_url.is_empty() { - return Err( - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "reason": "VECTOR_ENGINE_BASE_URL 未配置", - })), - ); - } - - let api_key = state - .config - .vector_engine_api_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "reason": "VECTOR_ENGINE_API_KEY 未配置", - })) - })?; - - Ok(VectorEngineAudioSettings { - base_url: base_url.to_string(), - api_key: api_key.to_string(), - request_timeout_ms: state.config.vector_engine_audio_request_timeout_ms.max(1), - }) -} - -fn build_vector_engine_audio_http_client( - settings: &VectorEngineAudioSettings, -) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms)) - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("构造 VectorEngine 音频生成 HTTP 客户端失败:{error}"), - })) - }) -} - -async fn post_vector_engine_json( - http_client: &reqwest::Client, - settings: &VectorEngineAudioSettings, - path: &str, - body: Value, - failure_context: &str, -) -> Result { - let response = http_client - .post(format!("{}{}", settings.base_url, path)) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .header(header::CONTENT_TYPE, "application/json") - .json(&body) - .send() - .await - .map_err(|error| vector_engine_bad_gateway(format!("{failure_context}:{error}")))?; - parse_vector_engine_response(response, failure_context).await -} - -async fn get_vector_engine_json( - http_client: &reqwest::Client, - settings: &VectorEngineAudioSettings, - path: &str, - failure_context: &str, -) -> Result { - let response = http_client - .get(format!("{}{}", settings.base_url, path)) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .send() - .await - .map_err(|error| vector_engine_bad_gateway(format!("{failure_context}:{error}")))?; - parse_vector_engine_response(response, failure_context).await -} - -async fn parse_vector_engine_response( - response: reqwest::Response, - failure_context: &str, -) -> Result { - let status = response.status(); - let raw_text = response.text().await.map_err(|error| { - vector_engine_bad_gateway(format!("{failure_context}:读取响应失败:{error}")) - })?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": failure_context, - "status": status.as_u16(), - "rawExcerpt": truncate_raw(raw_text.as_str()), - })), - ); - } - - let payload = serde_json::from_str::(&raw_text).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:解析响应失败:{error}"), - "rawExcerpt": truncate_raw(raw_text.as_str()), - })) - })?; - if let Some(code) = payload.get("code").and_then(Value::as_str) - && !matches!( - code.trim().to_ascii_lowercase().as_str(), - "success" | "succeeded" | "ok" - ) - { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": payload - .get("message") - .and_then(Value::as_str) - .unwrap_or(failure_context), - "code": code, - })), - ); - } - - Ok(payload) -} - -async fn download_generated_audio( - http_client: &reqwest::Client, - audio_url: &str, - provider: &str, -) -> Result { - let response = http_client - .get(audio_url) - .send() - .await - .map_err(|error| vector_engine_bad_gateway(format!("下载生成音频失败:{error}")))?; - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("audio/mpeg") - .to_string(); - let body = response - .bytes() - .await - .map_err(|error| vector_engine_bad_gateway(format!("读取生成音频内容失败:{error}")))?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": "下载生成音频失败", - "status": status.as_u16(), - })), - ); - } - if body.is_empty() || body.len() > MAX_GENERATED_AUDIO_BYTES { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": "生成音频内容为空或超过大小上限", - })), - ); - } - - let mime_type = normalize_audio_mime_type(&content_type, audio_url); - Ok(DownloadedAudio { - extension: audio_mime_to_extension(&mime_type).to_string(), - mime_type, - bytes: body.to_vec(), - }) -} - -fn extract_audio_urls(payload: &Value) -> Vec { - let mut urls = Vec::new(); - collect_audio_url_strings(payload, &mut urls); - let mut deduped = Vec::new(); - for url in urls { - if !deduped.contains(&url) { - deduped.push(url); - } - } - deduped -} - -fn collect_audio_url_strings(value: &Value, output: &mut Vec) { - match value { - Value::Object(object) => { - for (key, value) in object { - if let Some(raw) = value.as_str() - && looks_like_audio_url_key(key) - && looks_like_http_url(raw) - { - output.push(raw.trim().to_string()); - } - collect_audio_url_strings(value, output); - } - } - Value::Array(items) => { - for item in items { - collect_audio_url_strings(item, output); - } - } - Value::String(raw) if looks_like_http_url(raw) && looks_like_audio_url(raw) => { - output.push(raw.trim().to_string()); - } - _ => {} - } -} - -fn looks_like_audio_url_key(key: &str) -> bool { - let normalized = key.trim().to_ascii_lowercase(); - normalized.contains("audio") - || normalized.contains("wav") - || normalized.contains("mp3") - || normalized.contains("fileurl") - || normalized == "url" - || normalized.ends_with("_url") - || normalized.ends_with("url") -} - -fn looks_like_http_url(value: &str) -> bool { - let value = value.trim().to_ascii_lowercase(); - value.starts_with("http://") || value.starts_with("https://") -} - -fn looks_like_audio_url(value: &str) -> bool { - let value = value - .trim() - .split('?') - .next() - .unwrap_or_default() - .to_ascii_lowercase(); - value.ends_with(".mp3") - || value.ends_with(".wav") - || value.ends_with(".m4a") - || value.ends_with(".aac") - || value.ends_with(".ogg") - || value.ends_with(".webm") - || value.ends_with(".flac") -} - -fn normalize_audio_mime_type(content_type: &str, audio_url: &str) -> String { - let mime_type = content_type - .split(';') - .next() - .map(str::trim) - .filter(|value| value.starts_with("audio/")) - .unwrap_or(""); - match mime_type { - "audio/mpeg" | "audio/mp3" => "audio/mpeg".to_string(), - "audio/wav" | "audio/wave" | "audio/x-wav" => "audio/wav".to_string(), - "audio/ogg" => "audio/ogg".to_string(), - "audio/webm" => "audio/webm".to_string(), - "audio/aac" => "audio/aac".to_string(), - "audio/flac" => "audio/flac".to_string(), - "audio/mp4" | "audio/x-m4a" => "audio/mp4".to_string(), - _ => mime_type_from_audio_url(audio_url), - } -} - -fn mime_type_from_audio_url(audio_url: &str) -> String { - let path = audio_url - .split('?') - .next() - .unwrap_or_default() - .to_ascii_lowercase(); - if path.ends_with(".wav") { - "audio/wav".to_string() - } else if path.ends_with(".ogg") { - "audio/ogg".to_string() - } else if path.ends_with(".webm") { - "audio/webm".to_string() - } else if path.ends_with(".aac") { - "audio/aac".to_string() - } else if path.ends_with(".flac") { - "audio/flac".to_string() - } else if path.ends_with(".m4a") { - "audio/mp4".to_string() - } else { - "audio/mpeg".to_string() - } -} - -fn audio_mime_to_extension(mime_type: &str) -> &'static str { - match mime_type { - "audio/wav" => "wav", - "audio/ogg" => "ogg", - "audio/webm" => "webm", - "audio/aac" => "aac", - "audio/flac" => "flac", - "audio/mp4" => "m4a", - _ => "mp3", - } -} - -fn normalize_limited_text( - value: &str, - field: &'static str, - max_chars: usize, -) -> Result { - let normalized = value.trim().to_string(); - if normalized.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "field": field, - "message": format!("{field} 不能为空"), - })), - ); - } - if normalized.chars().count() > max_chars { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "field": field, - "message": format!("{field} 超过 {} 字符", max_chars), - })), - ); - } - Ok(normalized) -} - -fn normalize_limited_text_allow_empty( - value: &str, - field: &'static str, - max_chars: usize, -) -> Result { - let normalized = value.trim().to_string(); - if normalized.chars().count() > max_chars { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "field": field, - "message": format!("{field} 超过 {} 字符", max_chars), - })), - ); - } - Ok(normalized) -} - -fn normalize_optional_text(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn normalize_task_status(status: &str) -> String { - let normalized = status.trim().to_ascii_lowercase().replace(' ', "_"); - match normalized.as_str() { - "finish" | "finished" | "complete" | "completed" | "success" | "succeeded" => { - "completed".to_string() - } - "" => "processing".to_string(), - value => value.to_string(), - } -} - -fn is_pending_task_status(status: &str) -> bool { - matches!( - status, - "created" | "pending" | "queued" | "processing" | "running" | "submitted" | "started" - ) -} - -fn is_failed_task_status(status: &str) -> bool { - matches!( - status, - "failed" | "error" | "canceled" | "cancelled" | "rejected" | "expired" - ) -} - -fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { - match value { - Value::Object(object) => { - for (key, value) in object { - if key.eq_ignore_ascii_case(target_key) - && let Some(text) = value.as_str() - { - return Some(text.trim().to_string()); - } - if let Some(found) = find_first_string_by_key(value, target_key) { - return Some(found); - } - } - None - } - Value::Array(items) => items - .iter() - .find_map(|item| find_first_string_by_key(item, target_key)), - _ => None, - } -} - -fn extract_string_by_path(value: &Value, path: &[&str]) -> Option { - let mut current = value; - for key in path { - current = current.get(*key)?; - } - current.as_str().map(str::trim).map(ToOwned::to_owned) -} - -fn encode_path_segment(value: &str) -> String { - urlencoding::encode(value).into_owned() -} - -fn sanitize_audio_path_segment(raw: &str, fallback: &str) -> String { - let normalized = raw - .trim() - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::(); - let collapsed = normalized - .split('-') - .filter(|part| !part.is_empty()) - .collect::>() - .join("-"); - if collapsed.is_empty() { - fallback.to_string() - } else { - collapsed.chars().take(80).collect() - } -} - -fn truncate_raw(raw_text: &str) -> String { - raw_text.chars().take(800).collect() -} - -fn current_utc_micros() -> i64 { - shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc()) -} - -fn current_utc_iso_text() -> String { - shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc()) - .unwrap_or_else(|_| shared_kernel::format_timestamp_micros(current_utc_micros())) -} - -fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "asset-object", - "message": error.to_string(), - })) -} - -fn map_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "spacetimedb", - "message": error.to_string(), - })) -} - -fn vector_engine_bad_gateway(message: impl Into) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message.into(), - })) -} - -fn parse_json_payload( - request_context: &RequestContext, - payload: Result, JsonRejection>, -) -> Result, Response> { - payload.map_err(|rejection| { - AppError::from_status(StatusCode::BAD_REQUEST) - .with_message(format!("请求体 JSON 不合法:{rejection}")) - .into_response_with_context(Some(request_context)) - }) -} - +mod clock; +mod errors; +mod generation; +mod handlers; +mod persist; +mod publish; +mod settings; +mod targets; +mod tasks; #[cfg(test)] -mod tests { - use super::*; +mod tests; +mod types; - #[test] - fn normalizes_audio_mime_type_from_content_type_and_url() { - assert_eq!( - normalize_audio_mime_type("audio/x-wav; charset=utf-8", "https://x/a.bin"), - "audio/wav" - ); - assert_eq!( - normalize_audio_mime_type("application/octet-stream", "https://x/a.m4a?token=1"), - "audio/mp4" - ); - assert_eq!(audio_mime_to_extension("audio/mp4"), "m4a"); - } +pub use handlers::{ + create_background_music_task, create_sound_effect_task, + create_visual_novel_background_music_task, create_visual_novel_sound_effect_task, + publish_background_music_asset, publish_sound_effect_asset, + publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset, +}; - #[test] - fn extracts_nested_audio_urls() { - let payload = json!({ - "Response": { - "Status": "FINISH", - "Task": { - "Output": { - "FileInfos": [ - { "FileUrl": "https://cdn.example.test/audio.wav" } - ] - } - } - } - }); - - assert_eq!( - extract_audio_urls(&payload), - vec!["https://cdn.example.test/audio.wav".to_string()] - ); - } - - #[test] - fn vector_engine_task_status_is_stable() { - assert_eq!(normalize_task_status("FINISH"), "completed"); - assert!(is_pending_task_status("processing")); - assert!(is_failed_task_status("failed")); - } - - #[test] - fn creation_audio_billing_uses_lower_cost_for_background_music() { - let target = AudioAssetBindingTarget { - entity_kind: "puzzle_work".to_string(), - entity_id: "puzzle-profile-1".to_string(), - slot: "background_music".to_string(), - asset_kind: "puzzle_background_music".to_string(), - profile_id: Some("puzzle-profile-1".to_string()), - storage_prefix: LegacyAssetPrefix::PuzzleAssets, - storage_scope: "puzzle_work".to_string(), - }; - - assert_eq!( - resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target), - 5 - ); - assert_eq!( - resolve_creation_audio_points_cost(AudioAssetSlot::SoundEffect, &target), - 10 - ); - } - - #[test] - fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effects() { - let payload = creation_audio::PublishGeneratedAudioAssetRequest { - entity_kind: "puzzle_work".to_string(), - entity_id: "puzzle-profile-1".to_string(), - slot: "background_music".to_string(), - asset_kind: "puzzle_background_music".to_string(), - profile_id: Some("puzzle-profile-1".to_string()), - storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets), - }; - let error = creation_audio_generation_disabled_error_for_target(payload); - assert_eq!(error.status_code(), StatusCode::GONE); - - let payload = creation_audio::PublishGeneratedAudioAssetRequest { - entity_kind: "match3d_work".to_string(), - entity_id: "match3d-profile-1".to_string(), - slot: "background_music".to_string(), - asset_kind: "match3d_background_music".to_string(), - profile_id: Some("match3d-profile-1".to_string()), - storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets), - }; - let error = creation_audio_generation_disabled_error_for_target(payload); - assert_eq!(error.status_code(), StatusCode::GONE); - - let payload = creation_audio::PublishGeneratedAudioAssetRequest { - entity_kind: "match3d_item".to_string(), - entity_id: "match3d-item-1".to_string(), - slot: "click_sound".to_string(), - asset_kind: "match3d_click_sound".to_string(), - profile_id: Some("match3d-profile-1".to_string()), - storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets), - }; - let error = creation_audio_generation_disabled_error_for_target(payload); - assert_eq!(error.status_code(), StatusCode::GONE); - - let payload = creation_audio::PublishGeneratedAudioAssetRequest { - entity_kind: "wooden_fish_work".to_string(), - entity_id: "wooden-fish-profile-1".to_string(), - slot: "hit_sound".to_string(), - asset_kind: "wooden_fish_hit_sound".to_string(), - profile_id: Some("wooden-fish-profile-1".to_string()), - storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets), - }; - let error = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect) - .expect_err("wooden fish hit sound target should be disabled"); - assert_eq!(error.status_code(), StatusCode::GONE); - } - - #[test] - fn validates_prompt_length() { - let prompt = "声".repeat(VIDU_PROMPT_MAX_CHARS + 1); - let error = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS) - .expect_err("long prompt should fail"); - assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); - } -} +#[allow(unused_imports)] +pub(crate) use generation::generate_background_music_asset_for_creation; +pub(crate) use generation::generate_sound_effect_asset_for_creation; +pub(crate) use types::GeneratedCreationAudioTarget; diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/clock.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/clock.rs new file mode 100644 index 00000000..1cf544ae --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/clock.rs @@ -0,0 +1,8 @@ +pub(super) fn current_utc_micros() -> i64 { + shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc()) +} + +pub(super) fn current_utc_iso_text() -> String { + shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc()) + .unwrap_or_else(|_| shared_kernel::format_timestamp_micros(current_utc_micros())) +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/errors.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/errors.rs new file mode 100644 index 00000000..1d349c57 --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/errors.rs @@ -0,0 +1,120 @@ +use axum::{Json, extract::rejection::JsonRejection, http::StatusCode, response::Response}; +use platform_audio::{AudioError, AudioStatusHint}; +use serde_json::json; + +use crate::{http_error::AppError, request_context::RequestContext}; + +use super::types::VECTOR_ENGINE_PROVIDER; + +pub(super) fn normalize_limited_text( + value: &str, + field: &'static str, + max_chars: usize, +) -> Result { + let normalized = value.trim().to_string(); + if normalized.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "field": field, + "message": format!("{field} 不能为空"), + })), + ); + } + if normalized.chars().count() > max_chars { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "field": field, + "message": format!("{field} 超过 {} 字符", max_chars), + })), + ); + } + Ok(normalized) +} + +pub(super) fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +pub(super) fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "message": error.to_string(), + })) +} + +pub(super) fn map_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +pub(super) fn map_platform_audio_error(error: AudioError) -> AppError { + let status = match error.status_hint() { + AudioStatusHint::BadRequest => StatusCode::BAD_REQUEST, + AudioStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE, + AudioStatusHint::BadGateway => StatusCode::BAD_GATEWAY, + AudioStatusHint::GatewayTimeout => StatusCode::GATEWAY_TIMEOUT, + }; + let mut details = json!({ + "provider": error.provider(), + "message": error.message(), + }); + match &error { + AudioError::InvalidConfig { .. } | AudioError::InvalidRequest { .. } => {} + AudioError::Request { + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + .. + } => { + details["endpoint"] = json!(endpoint); + details["timeout"] = json!(timeout); + details["connect"] = json!(connect); + details["request"] = json!(request); + details["body"] = json!(body); + details["status"] = json!(status_code); + details["source"] = json!(source); + } + AudioError::Upstream { + upstream_status, + raw_excerpt, + .. + } => { + details["upstreamStatus"] = json!(upstream_status); + details["rawExcerpt"] = json!(raw_excerpt); + } + AudioError::ResponseParse { raw_excerpt, .. } => { + details["rawExcerpt"] = json!(raw_excerpt); + } + AudioError::MissingAudio { .. } => {} + } + AppError::from_status(status).with_details(details) +} + +pub(super) fn vector_engine_bad_gateway(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": message.into(), + })) +} + +pub(super) fn parse_json_payload( + request_context: &RequestContext, + payload: Result, JsonRejection>, +) -> Result, Response> { + payload.map_err(|rejection| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(format!("请求体 JSON 不合法:{rejection}")) + .into_response_with_context(Some(request_context)) + }) +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs new file mode 100644 index 00000000..a48dff16 --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -0,0 +1,122 @@ +use shared_contracts::creation_audio; + +use crate::{http_error::AppError, state::AppState}; + +use super::{ + clock::current_utc_iso_text, + errors::{map_platform_audio_error, vector_engine_bad_gateway}, + publish::wait_for_generated_audio_asset, + tasks::{create_background_music_task_response, create_sound_effect_task_response}, + types::{AudioAssetBindingTarget, AudioAssetSlot, GeneratedCreationAudioTarget}, +}; + +pub(crate) async fn generate_sound_effect_asset_for_creation( + state: &AppState, + owner_user_id: &str, + prompt: String, + duration: Option, + seed: Option, + target: GeneratedCreationAudioTarget, +) -> Result { + let normalized_prompt = platform_audio::normalize_limited_text( + &prompt, + "prompt", + platform_audio::VIDU_PROMPT_MAX_CHARS, + ) + .map_err(map_platform_audio_error)?; + let task = + create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?; + let target = AudioAssetBindingTarget { + storage_scope: target.entity_kind.clone(), + entity_kind: target.entity_kind, + entity_id: target.entity_id, + slot: target.slot, + asset_kind: target.asset_kind, + profile_id: target.profile_id, + storage_prefix: target.storage_prefix, + }; + let generated = wait_for_generated_audio_asset( + state, + owner_user_id, + task.task_id.clone(), + AudioAssetSlot::SoundEffect, + target, + ) + .await?; + let audio_src = generated + .audio_src + .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?; + + Ok(creation_audio::CreationAudioAsset { + task_id: generated.task_id, + provider: generated.provider, + asset_object_id: generated.asset_object_id, + asset_kind: generated.asset_kind, + audio_src, + prompt: Some(normalized_prompt), + title: None, + updated_at: Some(current_utc_iso_text()), + }) +} + +pub(crate) async fn generate_background_music_asset_for_creation( + state: &AppState, + owner_user_id: &str, + prompt: String, + title: String, + tags: Option, + model: Option, + target: GeneratedCreationAudioTarget, +) -> Result { + let normalized_prompt = platform_audio::normalize_limited_text_allow_empty( + &prompt, + "prompt", + platform_audio::SUNO_PROMPT_MAX_CHARS, + ) + .map_err(map_platform_audio_error)?; + let normalized_title = platform_audio::normalize_limited_text( + &title, + "title", + platform_audio::SUNO_TITLE_MAX_CHARS, + ) + .map_err(map_platform_audio_error)?; + let task = create_background_music_task_response( + state, + normalized_prompt.clone(), + normalized_title.clone(), + tags, + model, + ) + .await?; + let target = AudioAssetBindingTarget { + storage_scope: target.entity_kind.clone(), + entity_kind: target.entity_kind, + entity_id: target.entity_id, + slot: target.slot, + asset_kind: target.asset_kind, + profile_id: target.profile_id, + storage_prefix: target.storage_prefix, + }; + let generated = wait_for_generated_audio_asset( + state, + owner_user_id, + task.task_id.clone(), + AudioAssetSlot::BackgroundMusic, + target, + ) + .await?; + let audio_src = generated + .audio_src + .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?; + + Ok(creation_audio::CreationAudioAsset { + task_id: generated.task_id, + provider: generated.provider, + asset_object_id: generated.asset_object_id, + asset_kind: generated.asset_kind, + audio_src, + prompt: Some(normalized_prompt), + title: Some(normalized_title), + updated_at: Some(current_utc_iso_text()), + }) +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs new file mode 100644 index 00000000..8fd56e60 --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs @@ -0,0 +1,216 @@ +use axum::{ + Json, + extract::{Path, State, rejection::JsonRejection}, + response::Response, +}; +use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest}; +use serde_json::Value; +use shared_contracts::{creation_audio, visual_novel as contract}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, + request_context::RequestContext, state::AppState, +}; + +use super::{ + errors::{map_platform_audio_error, parse_json_payload}, + publish::publish_generated_audio_asset, + settings::require_vector_engine_audio_settings, + targets::{ + build_creation_audio_target, build_visual_novel_audio_target, + creation_audio_generation_disabled_error, + creation_audio_generation_disabled_error_for_target, + }, + types::AudioAssetSlot, +}; + +pub async fn create_visual_novel_background_music_task( + State(state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + let settings = require_vector_engine_audio_settings(&state)?; + let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) + .map_err(map_platform_audio_error)?; + let task = platform_audio::submit_background_music_task( + &http_client, + &settings, + BackgroundMusicTaskRequest { + prompt: payload.prompt, + title: payload.title, + tags: payload.tags, + model: payload.model, + instrumental: true, + }, + ) + .await + .map_err(map_platform_audio_error)?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelAudioGenerationTaskResponse { + kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic, + task_id: task.task_id, + provider: task.provider, + status: task.status, + }, + )) +} + +pub async fn create_background_music_task( + State(_state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let _ = parse_json_payload(&request_context, payload)?; + Err(creation_audio_generation_disabled_error() + .into_response_with_context(Some(&request_context))) +} + +pub async fn create_visual_novel_sound_effect_task( + State(state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + let settings = require_vector_engine_audio_settings(&state)?; + let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) + .map_err(map_platform_audio_error)?; + let task = platform_audio::submit_sound_effect_task( + &http_client, + &settings, + SoundEffectTaskRequest { + prompt: payload.prompt, + duration: payload + .duration + .unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS) + .clamp(2, 10), + seed: payload.seed, + }, + ) + .await + .map_err(map_platform_audio_error)?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelAudioGenerationTaskResponse { + kind: contract::VisualNovelAudioGenerationKind::SoundEffect, + task_id: task.task_id, + provider: task.provider, + status: task.status, + }, + )) +} + +pub async fn create_sound_effect_task( + State(_state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let _ = parse_json_payload(&request_context, payload)?; + Err(creation_audio_generation_disabled_error() + .into_response_with_context(Some(&request_context))) +} + +pub async fn publish_visual_novel_background_music_asset( + State(state): State, + Path(task_id): Path, + axum::extract::Extension(request_context): axum::extract::Extension, + axum::extract::Extension(authenticated): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let payload = parse_json_payload(&request_context, payload)?.0; + let target = build_visual_novel_audio_target(payload, AudioAssetSlot::BackgroundMusic)?; + publish_generated_audio_asset( + &state, + authenticated.claims().user_id(), + task_id, + AudioAssetSlot::BackgroundMusic, + target, + ) + .await + .map(|payload| { + json_success_body( + Some(&request_context), + contract::VisualNovelGeneratedAudioAssetResponse { + kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic, + task_id: payload.task_id, + provider: payload.provider, + status: payload.status, + asset_object_id: payload.asset_object_id, + asset_kind: payload.asset_kind, + audio_src: payload.audio_src, + }, + ) + }) + .map_err(|error| error.into_response_with_context(Some(&request_context))) +} + +pub async fn publish_visual_novel_sound_effect_asset( + State(state): State, + Path(task_id): Path, + axum::extract::Extension(request_context): axum::extract::Extension, + axum::extract::Extension(authenticated): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let payload = parse_json_payload(&request_context, payload)?.0; + let target = build_visual_novel_audio_target(payload, AudioAssetSlot::SoundEffect)?; + publish_generated_audio_asset( + &state, + authenticated.claims().user_id(), + task_id, + AudioAssetSlot::SoundEffect, + target, + ) + .await + .map(|payload| { + json_success_body( + Some(&request_context), + contract::VisualNovelGeneratedAudioAssetResponse { + kind: contract::VisualNovelAudioGenerationKind::SoundEffect, + task_id: payload.task_id, + provider: payload.provider, + status: payload.status, + asset_object_id: payload.asset_object_id, + asset_kind: payload.asset_kind, + audio_src: payload.audio_src, + }, + ) + }) + .map_err(|error| error.into_response_with_context(Some(&request_context))) +} + +pub async fn publish_background_music_asset( + State(_state): State, + Path(_task_id): Path, + axum::extract::Extension(request_context): axum::extract::Extension, + axum::extract::Extension(_authenticated): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let payload = parse_json_payload(&request_context, payload)?.0; + Err(creation_audio_generation_disabled_error_for_target(payload) + .into_response_with_context(Some(&request_context))) +} + +pub async fn publish_sound_effect_asset( + State(state): State, + Path(task_id): Path, + axum::extract::Extension(request_context): axum::extract::Extension, + axum::extract::Extension(authenticated): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let payload = parse_json_payload(&request_context, payload)?.0; + let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + publish_generated_audio_asset( + &state, + authenticated.claims().user_id(), + task_id, + AudioAssetSlot::SoundEffect, + target, + ) + .await + .map(|payload| json_success_body(Some(&request_context), payload)) + .map_err(|error| error.into_response_with_context(Some(&request_context))) +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs new file mode 100644 index 00000000..0802b852 --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/persist.rs @@ -0,0 +1,115 @@ +use axum::http::StatusCode; +use module_assets::{ + AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, + generate_asset_binding_id, generate_asset_object_id, +}; +use platform_audio::{DownloadedAudio, GeneratedAudioPersistInput, GeneratedAudioPersistTarget}; +use serde_json::json; + +use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState}; + +use super::{ + clock::current_utc_micros, + errors::{map_asset_field_error, map_spacetime_error}, + types::{AudioAssetBindingTarget, AudioAssetSlot}, +}; + +#[derive(Clone, Debug)] +pub(super) struct PersistedAudioAsset { + pub(super) asset_object_id: String, + pub(super) audio_src: String, +} + +pub(super) async fn persist_generated_audio_asset( + state: &AppState, + http_client: &reqwest::Client, + owner_user_id: &str, + task_id: &str, + slot: AudioAssetSlot, + target: AudioAssetBindingTarget, + audio: DownloadedAudio, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + + let audio_mime_type = audio.mime_type.clone(); + let put_request = + platform_audio::prepare_generated_audio_put_request(GeneratedAudioPersistInput { + owner_user_id: owner_user_id.to_string(), + task_id: task_id.to_string(), + task_kind: slot.task_kind(), + target: GeneratedAudioPersistTarget { + entity_kind: target.entity_kind.clone(), + entity_id: target.entity_id.clone(), + slot: target.slot.clone(), + asset_kind: target.asset_kind.clone(), + profile_id: target.profile_id.clone(), + storage_prefix: target.storage_prefix, + storage_scope: target.storage_scope.clone(), + }, + audio, + }); + let put_result = oss_client + .put_object(http_client, put_request) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let head = oss_client + .head_object( + http_client, + platform_oss::OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let now_micros = current_utc_micros(); + let asset_object = state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(audio_mime_type)), + head.content_length, + head.etag, + target.asset_kind.clone(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + target.profile_id.clone(), + Some(target.entity_id.clone()), + now_micros, + ) + .map_err(map_asset_field_error)?, + ) + .await + .map_err(map_spacetime_error)?; + state + .spacetime_client() + .bind_asset_object_to_entity( + build_asset_entity_binding_input( + generate_asset_binding_id(now_micros), + asset_object.asset_object_id.clone(), + target.entity_kind, + target.entity_id, + target.slot, + target.asset_kind, + Some(owner_user_id.to_string()), + target.profile_id, + now_micros, + ) + .map_err(map_asset_field_error)?, + ) + .await + .map_err(map_spacetime_error)?; + + Ok(PersistedAudioAsset { + asset_object_id: asset_object.asset_object_id, + audio_src: put_result.legacy_public_path, + }) +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs new file mode 100644 index 00000000..68795fea --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs @@ -0,0 +1,164 @@ +use std::time::Duration; + +use shared_contracts::creation_audio; + +use crate::{ + asset_billing::execute_billable_asset_operation_with_cost, http_error::AppError, + state::AppState, +}; + +use super::{ + errors::{map_platform_audio_error, vector_engine_bad_gateway}, + persist::persist_generated_audio_asset, + settings::require_vector_engine_audio_settings, + types::{ + AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST, + CREATION_SOUND_EFFECT_POINTS_COST, + }, +}; + +pub(super) async fn publish_generated_audio_asset( + state: &AppState, + owner_user_id: &str, + task_id: String, + slot: AudioAssetSlot, + target: AudioAssetBindingTarget, +) -> Result { + let task_id = platform_audio::normalize_limited_text(&task_id, "taskId", 160) + .map_err(map_platform_audio_error)?; + let settings = require_vector_engine_audio_settings(state)?; + let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) + .map_err(map_platform_audio_error)?; + let (status, audio_urls): (String, Vec) = + platform_audio::resolve_audio_task_download_urls( + &http_client, + &settings, + slot.task_kind(), + &task_id, + ) + .await + .map_err(map_platform_audio_error)?; + + if platform_audio::is_pending_task_status(&status) && audio_urls.is_empty() { + return Ok(creation_audio::GeneratedAudioAssetResponse { + kind: slot.creation_contract_kind(), + task_id, + provider: slot.provider().to_string(), + status: status.clone(), + asset_object_id: None, + asset_kind: None, + audio_src: None, + }); + } + + if platform_audio::is_failed_task_status(&status) { + return Err(vector_engine_bad_gateway( + "音频生成任务失败,请调整提示词后重试", + )); + } + + let audio_url = audio_urls + .into_iter() + .next() + .ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?; + let billing_asset_kind = target.asset_kind.clone(); + let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target); + let points_cost = resolve_creation_audio_points_cost(slot, &target); + let persisted = execute_billable_asset_operation_with_cost( + state, + owner_user_id, + billing_asset_kind.as_str(), + billing_asset_id.as_str(), + points_cost, + async { + let audio = + platform_audio::download_generated_audio(&http_client, &audio_url, slot.provider()) + .await + .map_err(map_platform_audio_error)?; + persist_generated_audio_asset( + state, + &http_client, + owner_user_id, + &task_id, + slot, + target.clone(), + audio, + ) + .await + }, + ) + .await?; + + Ok(creation_audio::GeneratedAudioAssetResponse { + kind: slot.creation_contract_kind(), + task_id, + provider: slot.provider().to_string(), + status: "completed".to_string(), + asset_object_id: Some(persisted.asset_object_id), + asset_kind: Some(target.asset_kind), + audio_src: Some(persisted.audio_src), + }) +} + +pub(super) async fn wait_for_generated_audio_asset( + state: &AppState, + owner_user_id: &str, + task_id: String, + slot: AudioAssetSlot, + target: AudioAssetBindingTarget, +) -> Result { + let mut latest_status = String::new(); + for _ in 0..40 { + let response = publish_generated_audio_asset( + state, + owner_user_id, + task_id.clone(), + slot, + target.clone(), + ) + .await?; + if response + .audio_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return Ok(response); + } + latest_status = response.status; + tokio::time::sleep(Duration::from_millis(3_000)).await; + } + + Err(vector_engine_bad_gateway(format!( + "音频生成超时:{}", + if latest_status.trim().is_empty() { + task_id + } else { + latest_status + } + ))) +} + +pub(super) fn build_audio_billing_asset_id( + task_id: &str, + slot: AudioAssetSlot, + target: &AudioAssetBindingTarget, +) -> String { + format!( + "creation-audio:{}:{}:{}:{}", + slot.file_stem(), + task_id, + target.entity_id, + target.slot + ) +} + +pub(super) fn resolve_creation_audio_points_cost( + slot: AudioAssetSlot, + _target: &AudioAssetBindingTarget, +) -> u64 { + match slot { + AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST, + AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST, + } +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/settings.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/settings.rs new file mode 100644 index 00000000..3b5c698f --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/settings.rs @@ -0,0 +1,44 @@ +use axum::http::StatusCode; +use platform_audio::VectorEngineAudioSettings; +use serde_json::json; + +use crate::{http_error::AppError, state::AppState}; + +use super::types::VECTOR_ENGINE_PROVIDER; + +pub(super) fn require_vector_engine_audio_settings( + state: &AppState, +) -> Result { + let base_url = state + .config + .vector_engine_base_url + .trim() + .trim_end_matches('/'); + if base_url.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "reason": "VECTOR_ENGINE_BASE_URL 未配置", + })), + ); + } + + let api_key = state + .config + .vector_engine_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "reason": "VECTOR_ENGINE_API_KEY 未配置", + })) + })?; + + Ok(VectorEngineAudioSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.vector_engine_audio_request_timeout_ms.max(1), + }) +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs new file mode 100644 index 00000000..086d224b --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/targets.rs @@ -0,0 +1,52 @@ +use axum::http::StatusCode; +use platform_oss::LegacyAssetPrefix; +use serde_json::json; +use shared_contracts::{creation_audio, visual_novel as contract}; + +use crate::http_error::AppError; + +use super::{ + errors::{normalize_limited_text, normalize_optional_text}, + types::{AUDIO_ENTITY_KIND, AudioAssetBindingTarget, AudioAssetSlot, VECTOR_ENGINE_PROVIDER}, +}; + +pub(super) fn build_visual_novel_audio_target( + payload: contract::PublishVisualNovelGeneratedAudioAssetRequest, + slot: AudioAssetSlot, +) -> Result { + let entity_id = normalize_limited_text(&payload.scene_id, "sceneId", 160)?; + Ok(AudioAssetBindingTarget { + entity_kind: AUDIO_ENTITY_KIND.to_string(), + entity_id, + slot: slot.slot().to_string(), + asset_kind: slot.asset_kind().to_string(), + profile_id: normalize_optional_text(payload.profile_id.as_deref()), + storage_prefix: LegacyAssetPrefix::CustomWorldScenes, + storage_scope: "visual-novel".to_string(), + }) +} + +pub(super) fn build_creation_audio_target( + payload: creation_audio::PublishGeneratedAudioAssetRequest, + _slot: AudioAssetSlot, +) -> Result { + Err(creation_audio_generation_disabled_error_for_target(payload)) +} + +pub(super) fn creation_audio_generation_disabled_error() -> AppError { + AppError::from_status(StatusCode::GONE).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": "当前创作音频目标未开放", + })) +} + +pub(super) fn creation_audio_generation_disabled_error_for_target( + payload: creation_audio::PublishGeneratedAudioAssetRequest, +) -> AppError { + creation_audio_generation_disabled_error().with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": "当前创作音频目标未开放", + "entityKind": payload.entity_kind.trim(), + "slot": payload.slot.trim(), + })) +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs new file mode 100644 index 00000000..0d52dccd --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs @@ -0,0 +1,69 @@ +use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest}; +use shared_contracts::creation_audio; + +use crate::{http_error::AppError, state::AppState}; + +use super::{errors::map_platform_audio_error, settings::require_vector_engine_audio_settings}; + +pub(super) async fn create_background_music_task_response( + state: &AppState, + prompt: String, + title: String, + tags: Option, + model: Option, +) -> Result { + let settings = require_vector_engine_audio_settings(state)?; + let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) + .map_err(map_platform_audio_error)?; + let task = platform_audio::submit_background_music_task( + &http_client, + &settings, + BackgroundMusicTaskRequest { + prompt, + title, + tags, + model, + instrumental: true, + }, + ) + .await + .map_err(map_platform_audio_error)?; + + Ok(creation_audio::AudioGenerationTaskResponse { + kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic, + task_id: task.task_id, + provider: task.provider, + status: task.status, + }) +} + +pub(super) async fn create_sound_effect_task_response( + state: &AppState, + prompt: String, + duration: Option, + seed: Option, +) -> Result { + let settings = require_vector_engine_audio_settings(state)?; + let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) + .map_err(map_platform_audio_error)?; + let task = platform_audio::submit_sound_effect_task( + &http_client, + &settings, + SoundEffectTaskRequest { + prompt, + duration: duration + .unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS) + .clamp(2, 10), + seed, + }, + ) + .await + .map_err(map_platform_audio_error)?; + + Ok(creation_audio::AudioGenerationTaskResponse { + kind: creation_audio::CreationAudioGenerationKind::SoundEffect, + task_id: task.task_id, + provider: task.provider, + status: task.status, + }) +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs new file mode 100644 index 00000000..566a5bd9 --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs @@ -0,0 +1,79 @@ +use axum::http::StatusCode; +use platform_oss::LegacyAssetPrefix; +use shared_contracts::creation_audio; + +use super::{ + publish::resolve_creation_audio_points_cost, + targets::{build_creation_audio_target, creation_audio_generation_disabled_error_for_target}, + types::{AudioAssetBindingTarget, AudioAssetSlot}, +}; + +#[test] +fn creation_audio_billing_uses_lower_cost_for_background_music() { + let target = AudioAssetBindingTarget { + entity_kind: "puzzle_work".to_string(), + entity_id: "puzzle-profile-1".to_string(), + slot: "background_music".to_string(), + asset_kind: "puzzle_background_music".to_string(), + profile_id: Some("puzzle-profile-1".to_string()), + storage_prefix: LegacyAssetPrefix::PuzzleAssets, + storage_scope: "puzzle_work".to_string(), + }; + + assert_eq!( + resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target), + 5 + ); + assert_eq!( + resolve_creation_audio_points_cost(AudioAssetSlot::SoundEffect, &target), + 10 + ); +} + +#[test] +fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effects() { + let payload = creation_audio::PublishGeneratedAudioAssetRequest { + entity_kind: "puzzle_work".to_string(), + entity_id: "puzzle-profile-1".to_string(), + slot: "background_music".to_string(), + asset_kind: "puzzle_background_music".to_string(), + profile_id: Some("puzzle-profile-1".to_string()), + storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets), + }; + let error = creation_audio_generation_disabled_error_for_target(payload); + assert_eq!(error.status_code(), StatusCode::GONE); + + let payload = creation_audio::PublishGeneratedAudioAssetRequest { + entity_kind: "match3d_work".to_string(), + entity_id: "match3d-profile-1".to_string(), + slot: "background_music".to_string(), + asset_kind: "match3d_background_music".to_string(), + profile_id: Some("match3d-profile-1".to_string()), + storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets), + }; + let error = creation_audio_generation_disabled_error_for_target(payload); + assert_eq!(error.status_code(), StatusCode::GONE); + + let payload = creation_audio::PublishGeneratedAudioAssetRequest { + entity_kind: "match3d_item".to_string(), + entity_id: "match3d-item-1".to_string(), + slot: "click_sound".to_string(), + asset_kind: "match3d_click_sound".to_string(), + profile_id: Some("match3d-profile-1".to_string()), + storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets), + }; + let error = creation_audio_generation_disabled_error_for_target(payload); + assert_eq!(error.status_code(), StatusCode::GONE); + + let payload = creation_audio::PublishGeneratedAudioAssetRequest { + entity_kind: "wooden_fish_work".to_string(), + entity_id: "wooden-fish-profile-1".to_string(), + slot: "hit_sound".to_string(), + asset_kind: "wooden_fish_hit_sound".to_string(), + profile_id: Some("wooden-fish-profile-1".to_string()), + storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets), + }; + let error = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect) + .expect_err("wooden fish hit sound target should be disabled"); + assert_eq!(error.status_code(), StatusCode::GONE); +} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs new file mode 100644 index 00000000..a424f46e --- /dev/null +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs @@ -0,0 +1,77 @@ +use platform_audio::AudioTaskKind; +use platform_oss::LegacyAssetPrefix; +use shared_contracts::creation_audio; + +pub(super) const VECTOR_ENGINE_PROVIDER: &str = platform_audio::VECTOR_ENGINE_PROVIDER; +pub(super) const AUDIO_ENTITY_KIND: &str = "visual_novel_scene"; +pub(super) const MUSIC_ASSET_KIND: &str = "visual_novel_music"; +pub(super) const AMBIENT_SOUND_ASSET_KIND: &str = "visual_novel_ambient_sound"; +pub(super) const MUSIC_SLOT: &str = "music"; +pub(super) const AMBIENT_SOUND_SLOT: &str = "ambient_sound"; +pub(super) const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5; +pub(super) const CREATION_SOUND_EFFECT_POINTS_COST: u64 = 10; + +#[derive(Clone, Debug)] +pub(super) struct AudioAssetBindingTarget { + pub(super) entity_kind: String, + pub(super) entity_id: String, + pub(super) slot: String, + pub(super) asset_kind: String, + pub(super) profile_id: Option, + pub(super) storage_prefix: LegacyAssetPrefix, + pub(super) storage_scope: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct GeneratedCreationAudioTarget { + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + pub profile_id: Option, + pub storage_prefix: LegacyAssetPrefix, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum AudioAssetSlot { + BackgroundMusic, + SoundEffect, +} + +impl AudioAssetSlot { + pub(super) fn task_kind(self) -> AudioTaskKind { + match self { + Self::BackgroundMusic => AudioTaskKind::BackgroundMusic, + Self::SoundEffect => AudioTaskKind::SoundEffect, + } + } + + pub(super) fn provider(self) -> &'static str { + self.task_kind().provider() + } + + pub(super) fn asset_kind(self) -> &'static str { + match self { + Self::BackgroundMusic => MUSIC_ASSET_KIND, + Self::SoundEffect => AMBIENT_SOUND_ASSET_KIND, + } + } + + pub(super) fn slot(self) -> &'static str { + match self { + Self::BackgroundMusic => MUSIC_SLOT, + Self::SoundEffect => AMBIENT_SOUND_SLOT, + } + } + + pub(super) fn file_stem(self) -> &'static str { + self.task_kind().file_stem() + } + + pub(super) fn creation_contract_kind(self) -> creation_audio::CreationAudioGenerationKind { + match self { + Self::BackgroundMusic => creation_audio::CreationAudioGenerationKind::BackgroundMusic, + Self::SoundEffect => creation_audio::CreationAudioGenerationKind::SoundEffect, + } + } +} diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 4eedff39..ae738867 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -254,11 +254,7 @@ pub async fn checkpoint_wooden_fish_run( let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; let run = state .spacetime_client() - .checkpoint_wooden_fish_run( - run_id, - principal.subject().to_string(), - payload, - ) + .checkpoint_wooden_fish_run(run_id, principal.subject().to_string(), payload) .await .map_err(|error| { wooden_fish_error_response( @@ -285,11 +281,7 @@ pub async fn finish_wooden_fish_run( let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; let run = state .spacetime_client() - .finish_wooden_fish_run( - run_id, - principal.subject().to_string(), - payload, - ) + .finish_wooden_fish_run(run_id, principal.subject().to_string(), payload) .await .map_err(|error| { wooden_fish_error_response( @@ -655,8 +647,10 @@ async fn generate_wooden_fish_image_assets( "message": "生成敲木鱼背景环境图失败:上游未返回图片", })) })?; - let background_reference_image = - downloaded_wooden_fish_reference_image(&background_image, "wooden-fish-generated-background"); + let background_reference_image = downloaded_wooden_fish_reference_image( + &background_image, + "wooden-fish-generated-background", + ); let background_asset = persist_wooden_fish_image_asset( state, owner_user_id, @@ -701,10 +695,8 @@ async fn generate_wooden_fish_image_assets( "message": "生成敲木鱼返回按钮图失败:上游未返回图片", })) })?; - let back_button_image = prepare_wooden_fish_green_screen_image_for_persist( - back_button_image, - "敲木鱼返回按钮图", - )?; + let back_button_image = + prepare_wooden_fish_green_screen_image_for_persist(back_button_image, "敲木鱼返回按钮图")?; let back_button_asset = persist_wooden_fish_image_asset( state, owner_user_id, @@ -1234,7 +1226,9 @@ mod tests { assert!(prompt.contains("圆形外沿加一圈和主题色搭配的干净外描边")); assert!(prompt.contains("只保留一个清晰、简洁、居中的向左返回箭头")); assert!(prompt.contains("不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案")); - assert!(prompt.contains("不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具")); + assert!( + prompt.contains("不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具") + ); assert!(prompt.contains("按钮底色不要使用与绿幕接近的纯绿色")); assert!(prompt.contains("主题为:玉米")); } @@ -1268,11 +1262,7 @@ mod tests { assert_eq!(processed.mime_type, "image/png"); assert_eq!(processed.extension, "png"); - assert_eq!( - decoded.get_pixel(0, 0).0[3], - 0, - "绿幕背景必须在入库前去除" - ); + assert_eq!(decoded.get_pixel(0, 0).0[3], 0, "绿幕背景必须在入库前去除"); assert_eq!(decoded.get_pixel(4, 4).0[3], 255); assert_eq!( decoded.get_pixel(6, 6).0[3], diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 596ef888..054ab40e 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -290,10 +290,7 @@ mod tests { assert!(wooden_fish.open); assert_eq!(wooden_fish.badge, "可创建"); assert_eq!(wooden_fish.sort_order, 47); - assert_eq!( - wooden_fish.image_src, - "/wooden-fish/default-hit-object.png" - ); + assert_eq!(wooden_fish.image_src, "/wooden-fish/default-hit-object.png"); } #[test] diff --git a/server-rs/crates/platform-audio/Cargo.toml b/server-rs/crates/platform-audio/Cargo.toml new file mode 100644 index 00000000..fa517699 --- /dev/null +++ b/server-rs/crates/platform-audio/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "platform-audio" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +platform-oss = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["time"] } +tracing = { workspace = true } +urlencoding = { workspace = true } diff --git a/server-rs/crates/platform-audio/src/client.rs b/server-rs/crates/platform-audio/src/client.rs new file mode 100644 index 00000000..b1233e05 --- /dev/null +++ b/server-rs/crates/platform-audio/src/client.rs @@ -0,0 +1,255 @@ +use std::error::Error; + +use reqwest::header; +use serde_json::Value; + +use crate::response::{ + extract_audio_urls, extract_string_by_path, find_first_string_by_key, normalize_task_status, +}; +use crate::{ + AudioError, AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest, + SoundEffectTaskRequest, VectorEngineAudioSettings, build_background_music_task_body, + build_sound_effect_task_body, +}; + +pub fn build_vector_engine_audio_http_client( + settings: &VectorEngineAudioSettings, +) -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_millis( + settings.request_timeout_ms.max(1), + )) + .build() + .map_err(|error| { + AudioError::invalid_config(format!( + "构造 VectorEngine 音频生成 HTTP 客户端失败:{error}" + )) + }) +} + +pub async fn submit_background_music_task( + http_client: &reqwest::Client, + settings: &VectorEngineAudioSettings, + request: BackgroundMusicTaskRequest, +) -> Result { + let body = build_background_music_task_body(request)?; + let response = post_vector_engine_json( + http_client, + settings, + AudioTaskKind::BackgroundMusic.submit_path(), + body, + "提交 Suno 背景音乐任务失败", + ) + .await?; + let task_id = extract_string_by_path(&response, &["data"]) + .or_else(|| find_first_string_by_key(&response, "task_id")) + .or_else(|| find_first_string_by_key(&response, "taskId")) + .ok_or_else(|| { + AudioError::missing_audio("提交 Suno 背景音乐任务失败:上游未返回任务 ID") + })?; + + Ok(AudioTaskResponse { + kind: AudioTaskKind::BackgroundMusic, + task_id, + provider: AudioTaskKind::BackgroundMusic.provider().to_string(), + status: "submitted".to_string(), + }) +} + +pub async fn submit_sound_effect_task( + http_client: &reqwest::Client, + settings: &VectorEngineAudioSettings, + request: SoundEffectTaskRequest, +) -> Result { + let body = build_sound_effect_task_body(request)?; + let response = post_vector_engine_json( + http_client, + settings, + AudioTaskKind::SoundEffect.submit_path(), + body, + "提交 Vidu 音效任务失败", + ) + .await?; + let task_id = find_first_string_by_key(&response, "task_id") + .or_else(|| find_first_string_by_key(&response, "taskId")) + .ok_or_else(|| AudioError::missing_audio("提交 Vidu 音效任务失败:上游未返回任务 ID"))?; + let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into()); + + Ok(AudioTaskResponse { + kind: AudioTaskKind::SoundEffect, + task_id, + provider: AudioTaskKind::SoundEffect.provider().to_string(), + status, + }) +} + +async fn fetch_audio_task_payload( + http_client: &reqwest::Client, + settings: &VectorEngineAudioSettings, + kind: AudioTaskKind, + task_id: &str, +) -> Result { + get_vector_engine_json( + http_client, + settings, + &kind.fetch_path(task_id), + match kind { + AudioTaskKind::BackgroundMusic => "查询 Suno 背景音乐任务失败", + AudioTaskKind::SoundEffect => "查询 Vidu 音效任务失败", + }, + ) + .await +} + +pub async fn resolve_audio_task_download_urls( + http_client: &reqwest::Client, + settings: &VectorEngineAudioSettings, + kind: AudioTaskKind, + task_id: &str, +) -> Result<(String, Vec), AudioError> { + let task_payload = fetch_audio_task_payload(http_client, settings, kind, task_id).await?; + let status = normalize_task_status( + find_first_string_by_key(&task_payload, "status") + .or_else(|| find_first_string_by_key(&task_payload, "state")) + .or_else(|| find_first_string_by_key(&task_payload, "Status")) + .as_deref() + .unwrap_or(""), + ); + let mut audio_urls = extract_audio_urls(&task_payload); + if kind == AudioTaskKind::BackgroundMusic && audio_urls.is_empty() { + if let Some(clip_id) = extract_string_by_path(&task_payload, &["data"]).and_then(|value| { + if value.trim().is_empty() { + None + } else { + Some(value) + } + }) { + let wav_payload = get_vector_engine_json( + http_client, + settings, + &format!("/suno/act/wav/{}", urlencoding::encode(clip_id.as_str())), + "获取 Suno wav 音频失败", + ) + .await?; + audio_urls = extract_audio_urls(&wav_payload); + } + } + Ok((status, audio_urls)) +} + +async fn get_vector_engine_json( + http_client: &reqwest::Client, + settings: &VectorEngineAudioSettings, + path: &str, + failure_context: &str, +) -> Result { + let response = http_client + .get(format!( + "{}{}", + settings.base_url.trim_end_matches('/'), + path + )) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .send() + .await + .map_err(|error| map_reqwest_error(failure_context, path, error))?; + parse_vector_engine_response(response, failure_context).await +} + +async fn post_vector_engine_json( + http_client: &reqwest::Client, + settings: &VectorEngineAudioSettings, + path: &str, + body: Value, + failure_context: &str, +) -> Result { + let response = http_client + .post(format!( + "{}{}", + settings.base_url.trim_end_matches('/'), + path + )) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json") + .json(&body) + .send() + .await + .map_err(|error| map_reqwest_error(failure_context, path, error))?; + parse_vector_engine_response(response, failure_context).await +} + +async fn parse_vector_engine_response( + response: reqwest::Response, + failure_context: &str, +) -> Result { + let status = response.status(); + let raw_text = response.text().await.map_err(|error| { + AudioError::request( + format!("{failure_context}:读取响应失败:{error}"), + None, + false, + false, + false, + true, + Some(status.as_u16()), + None, + ) + })?; + if !status.is_success() { + return Err(AudioError::upstream( + failure_context.to_string(), + status.as_u16(), + truncate_raw(raw_text.as_str()), + )); + } + + let payload = serde_json::from_str::(&raw_text).map_err(|error| { + AudioError::response_parse( + format!("{failure_context}:解析响应失败:{error}"), + truncate_raw(raw_text.as_str()), + ) + })?; + if let Some(code) = payload.get("code").and_then(Value::as_str) + && !matches!( + code.trim().to_ascii_lowercase().as_str(), + "success" | "succeeded" | "ok" + ) + { + return Err(AudioError::upstream( + payload + .get("message") + .and_then(Value::as_str) + .unwrap_or(failure_context) + .to_string(), + status.as_u16(), + truncate_raw(raw_text.as_str()), + )); + } + + Ok(payload) +} + +fn map_reqwest_error(failure_context: &str, endpoint: &str, error: reqwest::Error) -> AudioError { + AudioError::request( + format!("{failure_context}:{error}"), + Some(endpoint.to_string()), + error.is_timeout(), + error.is_connect(), + error.is_request(), + error.is_body(), + error.status().map(|status| status.as_u16()), + Error::source(&error).map(ToString::to_string), + ) +} + +fn truncate_raw(raw_text: &str) -> String { + raw_text.chars().take(800).collect() +} diff --git a/server-rs/crates/platform-audio/src/download.rs b/server-rs/crates/platform-audio/src/download.rs new file mode 100644 index 00000000..4aceda4a --- /dev/null +++ b/server-rs/crates/platform-audio/src/download.rs @@ -0,0 +1,118 @@ +use std::error::Error; + +use reqwest::header; + +use crate::{AudioError, DownloadedAudio, MAX_GENERATED_AUDIO_BYTES}; + +pub fn normalize_audio_mime_type(content_type: &str, audio_url: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .filter(|value| value.starts_with("audio/")) + .unwrap_or(""); + match mime_type { + "audio/mpeg" | "audio/mp3" => "audio/mpeg".to_string(), + "audio/wav" | "audio/wave" | "audio/x-wav" => "audio/wav".to_string(), + "audio/ogg" => "audio/ogg".to_string(), + "audio/webm" => "audio/webm".to_string(), + "audio/aac" => "audio/aac".to_string(), + "audio/flac" => "audio/flac".to_string(), + "audio/mp4" | "audio/x-m4a" => "audio/mp4".to_string(), + _ => mime_type_from_audio_url(audio_url), + } +} + +pub fn audio_mime_to_extension(mime_type: &str) -> &'static str { + match mime_type { + "audio/wav" => "wav", + "audio/ogg" => "ogg", + "audio/webm" => "webm", + "audio/aac" => "aac", + "audio/flac" => "flac", + "audio/mp4" => "m4a", + _ => "mp3", + } +} + +pub async fn download_generated_audio( + http_client: &reqwest::Client, + audio_url: &str, + _provider: &str, +) -> Result { + let response = http_client.get(audio_url).send().await.map_err(|error| { + AudioError::request( + format!("下载生成音频失败:{error}"), + Some(audio_url.to_string()), + error.is_timeout(), + error.is_connect(), + error.is_request(), + error.is_body(), + error.status().map(|status| status.as_u16()), + Error::source(&error).map(ToString::to_string), + ) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("audio/mpeg") + .to_string(); + let body = response.bytes().await.map_err(|error| { + AudioError::request( + format!("读取生成音频内容失败:{error}"), + Some(audio_url.to_string()), + false, + false, + false, + true, + None, + None, + ) + })?; + if !status.is_success() { + return Err(AudioError::upstream( + format!("下载生成音频失败:HTTP {}", status.as_u16()), + status.as_u16(), + truncate_raw(""), + )); + } + if body.is_empty() || body.len() > MAX_GENERATED_AUDIO_BYTES { + return Err(AudioError::missing_audio("生成音频内容为空或超过大小上限")); + } + + let mime_type = normalize_audio_mime_type(&content_type, audio_url); + Ok(DownloadedAudio { + extension: audio_mime_to_extension(&mime_type).to_string(), + mime_type, + bytes: body.to_vec(), + }) +} + +fn mime_type_from_audio_url(audio_url: &str) -> String { + let path = audio_url + .split('?') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if path.ends_with(".wav") { + "audio/wav".to_string() + } else if path.ends_with(".ogg") { + "audio/ogg".to_string() + } else if path.ends_with(".webm") { + "audio/webm".to_string() + } else if path.ends_with(".aac") { + "audio/aac".to_string() + } else if path.ends_with(".flac") { + "audio/flac".to_string() + } else if path.ends_with(".m4a") { + "audio/mp4".to_string() + } else { + "audio/mpeg".to_string() + } +} + +fn truncate_raw(raw_text: &str) -> String { + raw_text.chars().take(800).collect() +} diff --git a/server-rs/crates/platform-audio/src/error.rs b/server-rs/crates/platform-audio/src/error.rs new file mode 100644 index 00000000..ad4955dc --- /dev/null +++ b/server-rs/crates/platform-audio/src/error.rs @@ -0,0 +1,167 @@ +use std::{error::Error, fmt}; + +use crate::VECTOR_ENGINE_PROVIDER; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AudioStatusHint { + BadRequest, + ServiceUnavailable, + BadGateway, + GatewayTimeout, +} + +#[derive(Clone, Debug)] +pub enum AudioError { + InvalidConfig { + provider: &'static str, + message: String, + }, + InvalidRequest { + provider: &'static str, + message: String, + }, + Request { + provider: &'static str, + message: String, + endpoint: Option, + timeout: bool, + connect: bool, + request: bool, + body: bool, + status_code: Option, + source: Option, + }, + Upstream { + provider: &'static str, + message: String, + upstream_status: u16, + raw_excerpt: String, + }, + ResponseParse { + provider: &'static str, + message: String, + raw_excerpt: String, + }, + MissingAudio { + provider: &'static str, + message: String, + }, +} + +impl AudioError { + pub fn provider(&self) -> &'static str { + match self { + Self::InvalidConfig { provider, .. } + | Self::InvalidRequest { provider, .. } + | Self::Request { provider, .. } + | Self::Upstream { provider, .. } + | Self::ResponseParse { provider, .. } + | Self::MissingAudio { provider, .. } => provider, + } + } + + pub fn message(&self) -> &str { + match self { + Self::InvalidConfig { message, .. } + | Self::InvalidRequest { message, .. } + | Self::Request { message, .. } + | Self::Upstream { message, .. } + | Self::ResponseParse { message, .. } + | Self::MissingAudio { message, .. } => message, + } + } + + pub fn status_hint(&self) -> AudioStatusHint { + match self { + Self::InvalidConfig { .. } => AudioStatusHint::ServiceUnavailable, + Self::InvalidRequest { .. } => AudioStatusHint::BadRequest, + Self::Request { + timeout, + status_code, + .. + } if *timeout => AudioStatusHint::GatewayTimeout, + Self::Request { status_code, .. } + if status_code.is_some_and(|status| status >= 500) => + { + AudioStatusHint::BadGateway + } + Self::Upstream { .. } | Self::ResponseParse { .. } | Self::MissingAudio { .. } => { + AudioStatusHint::BadGateway + } + Self::Request { .. } => AudioStatusHint::BadGateway, + } + } + + pub fn invalid_config(message: impl Into) -> Self { + Self::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message: message.into(), + } + } + + pub fn invalid_request(message: impl Into) -> Self { + Self::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: message.into(), + } + } + + pub fn request( + message: impl Into, + endpoint: Option, + timeout: bool, + connect: bool, + request: bool, + body: bool, + status_code: Option, + source: Option, + ) -> Self { + Self::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: message.into(), + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + } + } + + pub fn upstream( + message: impl Into, + upstream_status: u16, + raw_excerpt: impl Into, + ) -> Self { + Self::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message: message.into(), + upstream_status, + raw_excerpt: raw_excerpt.into(), + } + } + + pub fn response_parse(message: impl Into, raw_excerpt: impl Into) -> Self { + Self::ResponseParse { + provider: VECTOR_ENGINE_PROVIDER, + message: message.into(), + raw_excerpt: raw_excerpt.into(), + } + } + + pub fn missing_audio(message: impl Into) -> Self { + Self::MissingAudio { + provider: VECTOR_ENGINE_PROVIDER, + message: message.into(), + } + } +} + +impl fmt::Display for AudioError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message()) + } +} + +impl Error for AudioError {} diff --git a/server-rs/crates/platform-audio/src/lib.rs b/server-rs/crates/platform-audio/src/lib.rs new file mode 100644 index 00000000..4d9235ef --- /dev/null +++ b/server-rs/crates/platform-audio/src/lib.rs @@ -0,0 +1,32 @@ +mod client; +mod download; +mod error; +mod persist; +mod request; +mod response; +mod types; + +pub use client::{ + build_vector_engine_audio_http_client, resolve_audio_task_download_urls, + submit_background_music_task, submit_sound_effect_task, +}; +pub use download::{audio_mime_to_extension, download_generated_audio, normalize_audio_mime_type}; +pub use error::{AudioError, AudioStatusHint}; +pub use persist::{ + GeneratedAudioPersistInput, GeneratedAudioPersistTarget, prepare_generated_audio_put_request, +}; +pub use request::{ + build_background_music_task_body, build_sound_effect_task_body, normalize_limited_text, + normalize_limited_text_allow_empty, normalize_optional_text, +}; +pub use response::{ + extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_task_status, +}; +pub use types::{ + AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest, + DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, MAX_GENERATED_AUDIO_BYTES, + SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, + SoundEffectTaskRequest, VECTOR_ENGINE_PROVIDER, VECTOR_ENGINE_SUNO_PROVIDER, + VECTOR_ENGINE_VIDU_PROVIDER, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS, + VectorEngineAudioSettings, +}; diff --git a/server-rs/crates/platform-audio/src/persist.rs b/server-rs/crates/platform-audio/src/persist.rs new file mode 100644 index 00000000..b1e4b910 --- /dev/null +++ b/server-rs/crates/platform-audio/src/persist.rs @@ -0,0 +1,106 @@ +use std::collections::BTreeMap; + +use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest}; + +use crate::{AudioTaskKind, DownloadedAudio}; + +#[derive(Clone, Debug)] +pub struct GeneratedAudioPersistTarget { + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + pub profile_id: Option, + pub storage_prefix: LegacyAssetPrefix, + pub storage_scope: String, +} + +#[derive(Clone, Debug)] +pub struct GeneratedAudioPersistInput { + pub owner_user_id: String, + pub task_id: String, + pub task_kind: AudioTaskKind, + pub target: GeneratedAudioPersistTarget, + pub audio: DownloadedAudio, +} + +pub fn prepare_generated_audio_put_request( + input: GeneratedAudioPersistInput, +) -> OssPutObjectRequest { + let file_name = format!( + "{}-{}.{}", + input.task_kind.file_stem(), + input.task_id, + input.audio.extension + ); + OssPutObjectRequest { + prefix: input.target.storage_prefix, + path_segments: vec![ + input.target.storage_scope.clone(), + input + .target + .profile_id + .clone() + .unwrap_or_else(|| "draft".to_string()), + input.target.entity_id.clone(), + input.target.slot.clone(), + ] + .into_iter() + .map(|segment| sanitize_audio_path_segment(segment.as_str(), "audio")) + .collect(), + file_name, + content_type: Some(input.audio.mime_type), + access: OssObjectAccess::Private, + metadata: build_audio_asset_metadata( + input.owner_user_id.as_str(), + input.target.profile_id.as_deref(), + &input.target, + input.task_kind, + ), + body: input.audio.bytes, + } +} + +fn build_audio_asset_metadata( + owner_user_id: &str, + profile_id: Option<&str>, + target: &GeneratedAudioPersistTarget, + task_kind: AudioTaskKind, +) -> BTreeMap { + let mut metadata = BTreeMap::from([ + ("asset-kind".to_string(), target.asset_kind.clone()), + ("owner-user-id".to_string(), owner_user_id.to_string()), + ("entity-kind".to_string(), target.entity_kind.clone()), + ("entity-id".to_string(), target.entity_id.clone()), + ("slot".to_string(), target.slot.clone()), + ("provider".to_string(), task_kind.provider().to_string()), + ]); + if let Some(profile_id) = profile_id { + metadata.insert("profile-id".to_string(), profile_id.to_string()); + } + metadata +} + +fn sanitize_audio_path_segment(raw: &str, fallback: &str) -> String { + let normalized = raw + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + let collapsed = normalized + .split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-"); + if collapsed.is_empty() { + fallback.to_string() + } else { + collapsed.chars().take(80).collect() + } +} diff --git a/server-rs/crates/platform-audio/src/request.rs b/server-rs/crates/platform-audio/src/request.rs new file mode 100644 index 00000000..ea864d87 --- /dev/null +++ b/server-rs/crates/platform-audio/src/request.rs @@ -0,0 +1,94 @@ +use serde_json::{Map, Value, json}; + +use crate::{ + AudioError, BackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS, + SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VIDU_AUDIO_MODEL, + VIDU_PROMPT_MAX_CHARS, +}; + +pub fn build_background_music_task_body( + request: BackgroundMusicTaskRequest, +) -> Result { + let prompt = + normalize_limited_text_allow_empty(&request.prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?; + let title = normalize_limited_text(&request.title, "title", SUNO_TITLE_MAX_CHARS)?; + let tags = request + .tags + .as_deref() + .map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS)) + .transpose()?; + let model = normalize_optional_text(request.model.as_deref()) + .unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string()); + + let mut body = Map::from_iter([ + ("prompt".to_string(), Value::String(prompt)), + ("mv".to_string(), Value::String(model)), + ("title".to_string(), Value::String(title)), + ("task".to_string(), Value::String("generate".to_string())), + ( + "make_instrumental".to_string(), + Value::Bool(request.instrumental), + ), + ]); + if let Some(tags) = tags { + body.insert("tags".to_string(), Value::String(tags)); + } + Ok(Value::Object(body)) +} + +pub fn build_sound_effect_task_body(request: SoundEffectTaskRequest) -> Result { + let prompt = normalize_limited_text(&request.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?; + let duration = request.duration.clamp(2, 10); + let mut body = Map::from_iter([ + ( + "model".to_string(), + Value::String(VIDU_AUDIO_MODEL.to_string()), + ), + ("prompt".to_string(), Value::String(prompt)), + ("duration".to_string(), json!(duration)), + ]); + if let Some(seed) = request.seed { + body.insert("seed".to_string(), json!(seed)); + } + Ok(Value::Object(body)) +} + +pub fn normalize_limited_text( + value: &str, + field: &'static str, + max_chars: usize, +) -> Result { + let normalized = value.trim().to_string(); + if normalized.is_empty() { + return Err(AudioError::invalid_request(format!("{field} 不能为空"))); + } + if normalized.chars().count() > max_chars { + return Err(AudioError::invalid_request(format!( + "{field} 超过 {} 字符", + max_chars + ))); + } + Ok(normalized) +} + +pub fn normalize_limited_text_allow_empty( + value: &str, + field: &'static str, + max_chars: usize, +) -> Result { + let normalized = value.trim().to_string(); + if normalized.chars().count() > max_chars { + return Err(AudioError::invalid_request(format!( + "{field} 超过 {} 字符", + max_chars + ))); + } + Ok(normalized) +} + +pub fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} diff --git a/server-rs/crates/platform-audio/src/response.rs b/server-rs/crates/platform-audio/src/response.rs new file mode 100644 index 00000000..554cad84 --- /dev/null +++ b/server-rs/crates/platform-audio/src/response.rs @@ -0,0 +1,125 @@ +use serde_json::Value; + +pub fn normalize_task_status(status: &str) -> String { + let normalized = status.trim().to_ascii_lowercase().replace(' ', "_"); + match normalized.as_str() { + "finish" | "finished" | "complete" | "completed" | "success" | "succeeded" => { + "completed".to_string() + } + "" => "processing".to_string(), + value => value.to_string(), + } +} + +pub fn is_pending_task_status(status: &str) -> bool { + matches!( + status, + "created" | "pending" | "queued" | "processing" | "running" | "submitted" | "started" + ) +} + +pub fn is_failed_task_status(status: &str) -> bool { + matches!( + status, + "failed" | "error" | "canceled" | "cancelled" | "rejected" | "expired" + ) +} + +pub fn extract_audio_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_audio_url_strings(payload, &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +pub(crate) fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + match value { + Value::Object(object) => { + for (key, value) in object { + if key.eq_ignore_ascii_case(target_key) + && let Some(text) = value.as_str() + { + return Some(text.trim().to_string()); + } + if let Some(found) = find_first_string_by_key(value, target_key) { + return Some(found); + } + } + None + } + Value::Array(items) => items + .iter() + .find_map(|item| find_first_string_by_key(item, target_key)), + _ => None, + } +} + +pub(crate) fn extract_string_by_path(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + current.as_str().map(str::trim).map(ToOwned::to_owned) +} + +fn collect_audio_url_strings(value: &Value, output: &mut Vec) { + match value { + Value::Object(object) => { + for (key, value) in object { + if let Some(raw) = value.as_str() + && looks_like_audio_url_key(key) + && looks_like_http_url(raw) + { + output.push(raw.trim().to_string()); + } + collect_audio_url_strings(value, output); + } + } + Value::Array(items) => { + for item in items { + collect_audio_url_strings(item, output); + } + } + Value::String(raw) if looks_like_http_url(raw) && looks_like_audio_url(raw) => { + output.push(raw.trim().to_string()); + } + _ => {} + } +} + +fn looks_like_audio_url_key(key: &str) -> bool { + let normalized = key.trim().to_ascii_lowercase(); + normalized.contains("audio") + || normalized.contains("wav") + || normalized.contains("mp3") + || normalized.contains("fileurl") + || normalized == "url" + || normalized.ends_with("_url") + || normalized.ends_with("url") +} + +fn looks_like_http_url(value: &str) -> bool { + let value = value.trim().to_ascii_lowercase(); + value.starts_with("http://") || value.starts_with("https://") +} + +fn looks_like_audio_url(value: &str) -> bool { + let value = value + .trim() + .split('?') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + value.ends_with(".mp3") + || value.ends_with(".wav") + || value.ends_with(".m4a") + || value.ends_with(".aac") + || value.ends_with(".ogg") + || value.ends_with(".webm") + || value.ends_with(".flac") +} diff --git a/server-rs/crates/platform-audio/src/types.rs b/server-rs/crates/platform-audio/src/types.rs new file mode 100644 index 00000000..b23faa1b --- /dev/null +++ b/server-rs/crates/platform-audio/src/types.rs @@ -0,0 +1,87 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AudioTaskKind { + BackgroundMusic, + SoundEffect, +} + +impl AudioTaskKind { + pub fn provider(self) -> &'static str { + match self { + Self::BackgroundMusic => VECTOR_ENGINE_SUNO_PROVIDER, + Self::SoundEffect => VECTOR_ENGINE_VIDU_PROVIDER, + } + } + + pub fn submit_path(self) -> &'static str { + match self { + Self::BackgroundMusic => "/suno/submit/music", + Self::SoundEffect => "/ent/v2/text2audio", + } + } + + pub fn fetch_path(self, task_id: &str) -> String { + match self { + Self::BackgroundMusic => format!("/suno/fetch/{}", urlencoding::encode(task_id)), + Self::SoundEffect => { + format!("/ent/v2/tasks/{}/creations", urlencoding::encode(task_id)) + } + } + } + + pub fn file_stem(self) -> &'static str { + match self { + Self::BackgroundMusic => "background-music", + Self::SoundEffect => "sound-effect", + } + } +} + +#[derive(Clone, Debug)] +pub struct BackgroundMusicTaskRequest { + pub prompt: String, + pub title: String, + pub tags: Option, + pub model: Option, + pub instrumental: bool, +} + +#[derive(Clone, Debug)] +pub struct SoundEffectTaskRequest { + pub prompt: String, + pub duration: u8, + pub seed: Option, +} + +#[derive(Clone, Debug)] +pub struct AudioTaskResponse { + pub kind: AudioTaskKind, + pub task_id: String, + pub provider: String, + pub status: String, +} + +#[derive(Clone, Debug)] +pub struct VectorEngineAudioSettings { + pub base_url: String, + pub api_key: String, + pub request_timeout_ms: u64, +} + +#[derive(Clone, Debug)] +pub struct DownloadedAudio { + pub bytes: Vec, + pub mime_type: String, + pub extension: String, +} + +pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; +pub const VECTOR_ENGINE_SUNO_PROVIDER: &str = "vector-engine-suno"; +pub const VECTOR_ENGINE_VIDU_PROVIDER: &str = "vector-engine-vidu"; +pub const SUNO_DEFAULT_MODEL: &str = "chirp-v4"; +pub const VIDU_AUDIO_MODEL: &str = "audio1.0"; +pub const SUNO_PROMPT_MAX_CHARS: usize = 5_000; +pub const SUNO_TITLE_MAX_CHARS: usize = 80; +pub const SUNO_TAGS_MAX_CHARS: usize = 160; +pub const VIDU_PROMPT_MAX_CHARS: usize = 1_500; +pub const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5; +pub const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024; diff --git a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs new file mode 100644 index 00000000..179daf47 --- /dev/null +++ b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs @@ -0,0 +1,84 @@ +use platform_audio::{ + AudioTaskKind, BackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, VIDU_PROMPT_MAX_CHARS, + audio_mime_to_extension, build_background_music_task_body, build_sound_effect_task_body, + extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_audio_mime_type, + normalize_task_status, +}; +use serde_json::json; + +#[test] +fn normalizes_audio_mime_type_from_content_type_and_url() { + assert_eq!( + normalize_audio_mime_type("audio/x-wav; charset=utf-8", "https://x/a.bin"), + "audio/wav" + ); + assert_eq!( + normalize_audio_mime_type("application/octet-stream", "https://x/a.m4a?token=1"), + "audio/mp4" + ); + assert_eq!(audio_mime_to_extension("audio/mp4"), "m4a"); +} + +#[test] +fn extracts_nested_audio_urls() { + let payload = json!({ + "Response": { + "Status": "FINISH", + "Task": { + "Output": { + "FileInfos": [ + { "FileUrl": "https://cdn.example.test/audio.wav" } + ] + } + } + } + }); + + assert_eq!( + extract_audio_urls(&payload), + vec!["https://cdn.example.test/audio.wav".to_string()] + ); +} + +#[test] +fn vector_engine_task_status_is_stable() { + assert_eq!(normalize_task_status("FINISH"), "completed"); + assert!(is_pending_task_status("processing")); + assert!(is_failed_task_status("failed")); +} + +#[test] +fn background_music_request_body_uses_default_model_and_optional_instrumental_flag() { + let body = build_background_music_task_body(BackgroundMusicTaskRequest { + prompt: " 风里的木琴 ".to_string(), + title: " 林间 ".to_string(), + tags: Some(" warm, wood ".to_string()), + model: None, + instrumental: true, + }) + .expect("request body should be valid"); + + assert_eq!(body["prompt"], "风里的木琴"); + assert_eq!(body["title"], "林间"); + assert_eq!(body["tags"], "warm, wood"); + assert_eq!(body["mv"], SUNO_DEFAULT_MODEL); + assert_eq!(body["task"], "generate"); + assert_eq!(body["make_instrumental"], true); + assert_eq!( + AudioTaskKind::BackgroundMusic.provider(), + "vector-engine-suno" + ); +} + +#[test] +fn sound_effect_request_rejects_overlong_prompt() { + let prompt = "声".repeat(VIDU_PROMPT_MAX_CHARS + 1); + let error = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest { + prompt, + duration: 5, + seed: None, + }) + .expect_err("long prompt should fail"); + + assert!(error.message().contains("prompt 超过")); +} diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 9e2a657a..ab9062df 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -1507,7 +1507,9 @@ impl RuntimeGuestTokenClaims { let issued_at_unix = issued_at.unix_timestamp(); if issued_at_unix < 0 { - return Err(JwtError::InvalidClaims("runtime guest JWT iat 不能早于 Unix epoch")); + return Err(JwtError::InvalidClaims( + "runtime guest JWT iat 不能早于 Unix epoch", + )); } let expires_at = issued_at @@ -1516,10 +1518,14 @@ impl RuntimeGuestTokenClaims { JwtError::InvalidConfig("runtime guest JWT 过期时间超出 i64 上限") })?, )) - .ok_or(JwtError::InvalidConfig("runtime guest JWT 过期时间计算溢出"))?; + .ok_or(JwtError::InvalidConfig( + "runtime guest JWT 过期时间计算溢出", + ))?; let expires_at_unix = expires_at.unix_timestamp(); if expires_at_unix <= issued_at_unix { - return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat")); + return Err(JwtError::InvalidClaims( + "runtime guest JWT exp 必须晚于 iat", + )); } let claims = Self { @@ -1558,7 +1564,9 @@ impl RuntimeGuestTokenClaims { return Err(JwtError::InvalidClaims("runtime guest JWT typ 非法")); } if self.exp <= self.iat { - return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat")); + return Err(JwtError::InvalidClaims( + "runtime guest JWT exp 必须晚于 iat", + )); } Ok(()) } @@ -1668,7 +1676,9 @@ pub fn verify_runtime_guest_token( ) -> Result { let token = token.trim(); if token.is_empty() { - return Err(JwtError::VerifyFailed("runtime guest JWT 不能为空".to_string())); + return Err(JwtError::VerifyFailed( + "runtime guest JWT 不能为空".to_string(), + )); } let mut validation = Validation::new(ACCESS_TOKEN_ALGORITHM); @@ -2378,7 +2388,10 @@ mod tests { assert_eq!(verified.subject(), "guest-runtime-123"); assert_eq!(verified.scope(), RUNTIME_GUEST_SCOPE_PUBLIC_PLAY); assert_eq!(verified.typ, RUNTIME_GUEST_TOKEN_TYPE); - assert_eq!(verified.expires_at_unix() - verified.iat, DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS); + assert_eq!( + verified.expires_at_unix() - verified.iat, + DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS + ); } #[test] diff --git a/server-rs/crates/platform-hyper3d/Cargo.toml b/server-rs/crates/platform-hyper3d/Cargo.toml new file mode 100644 index 00000000..53f0311a --- /dev/null +++ b/server-rs/crates/platform-hyper3d/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "platform-hyper3d" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +base64 = { workspace = true } +reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } +serde_json = { workspace = true } +shared-contracts = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/server-rs/crates/platform-hyper3d/src/client.rs b/server-rs/crates/platform-hyper3d/src/client.rs new file mode 100644 index 00000000..fe295905 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/client.rs @@ -0,0 +1,179 @@ +use reqwest::multipart; +use serde_json::json; +use shared_contracts::hyper3d as contract; + +use crate::{ + error::Hyper3dError, + request::{ + build_common_submit_fields, build_submit_options_from_image, + build_submit_options_from_text, decode_image_data_urls, normalize_condition_mode, + normalize_optional_limited_text, normalize_required_opaque_text, normalize_required_text, + }, + response::{ + build_submit_response, extract_download_files, extract_job_statuses, + resolve_hyper3d_overall_status, + }, + transport::{post_hyper3d_json, post_hyper3d_multipart}, + types::{ + HYPER3D_PROVIDER, Hyper3dSettings, MAX_IMAGE_COUNT, MAX_NEGATIVE_PROMPT_CHARS, + MAX_PROMPT_CHARS, RODIN_GEN2_TIER, + }, +}; + +pub fn build_hyper3d_http_client( + settings: &Hyper3dSettings, +) -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_millis( + settings.request_timeout_ms.max(1), + )) + .build() + .map_err(|error| { + Hyper3dError::invalid_config( + "build_hyper3d_http_client", + format!("构造 Hyper3D HTTP 客户端失败:{error}"), + ) + }) +} + +pub async fn submit_text_to_model( + state: &Hyper3dSettings, + payload: contract::Hyper3dTextToModelRequest, +) -> Result { + let http_client = build_hyper3d_http_client(state)?; + let prompt = normalize_required_text(&payload.prompt, "prompt", MAX_PROMPT_CHARS)?; + let options = build_submit_options_from_text(&payload)?; + let mut form = multipart::Form::new() + .text("tier", RODIN_GEN2_TIER.to_string()) + .text("prompt", prompt); + form = build_common_submit_fields(form, &options)?; + if let Some(negative_prompt) = normalize_optional_limited_text( + payload.negative_prompt.as_deref(), + MAX_NEGATIVE_PROMPT_CHARS, + )? { + form = form.text("negative_prompt", negative_prompt); + } + + let response = post_hyper3d_multipart( + &http_client, + state, + "/rodin", + form, + "提交 Hyper3D 文生模型任务失败", + ) + .await?; + + build_submit_response(contract::Hyper3dGenerationMode::TextToModel, response) +} + +pub async fn submit_image_to_model( + state: &Hyper3dSettings, + payload: contract::Hyper3dImageToModelRequest, +) -> Result { + let http_client = build_hyper3d_http_client(state)?; + let options = build_submit_options_from_image(&payload)?; + let mut form = multipart::Form::new().text("tier", RODIN_GEN2_TIER.to_string()); + form = build_common_submit_fields(form, &options)?; + let condition_mode = normalize_condition_mode(payload.condition_mode.as_deref())?; + form = form.text("condition_mode", condition_mode); + if let Some(prompt) = + normalize_optional_limited_text(payload.prompt.as_deref(), MAX_PROMPT_CHARS)? + { + form = form.text("prompt", prompt); + } + for image_url in payload + .image_urls + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + form = form.text("image_urls", image_url.to_string()); + } + for image in decode_image_data_urls(&payload.image_data_urls)? { + let part = multipart::Part::bytes(image.bytes) + .file_name(image.file_name) + .mime_str(&image.mime_type) + .map_err(|error| { + Hyper3dError::invalid_request( + Some("imageDataUrls"), + format!("构造图生模型图片字段失败:{error}"), + ) + })?; + form = form.part("images", part); + } + + if payload.image_data_urls.is_empty() && payload.image_urls.is_empty() { + return Err(Hyper3dError::invalid_request( + Some("imageDataUrls"), + "图生模型至少需要一张参考图", + )); + } + if payload.image_data_urls.len() + payload.image_urls.len() > MAX_IMAGE_COUNT { + return Err(Hyper3dError::invalid_request( + Some("imageDataUrls"), + format!("图生模型最多支持 {} 张参考图", MAX_IMAGE_COUNT), + )); + } + + let response = post_hyper3d_multipart( + &http_client, + state, + "/rodin", + form, + "提交 Hyper3D 图生模型任务失败", + ) + .await?; + + build_submit_response(contract::Hyper3dGenerationMode::ImageToModel, response) +} + +pub async fn query_task_status( + state: &Hyper3dSettings, + payload: contract::Hyper3dTaskStatusRequest, +) -> Result { + let http_client = build_hyper3d_http_client(state)?; + let subscription_key = + normalize_required_opaque_text(&payload.subscription_key, "subscriptionKey")?; + let response = post_hyper3d_json( + &http_client, + state, + "/status", + json!({ "subscription_key": subscription_key }), + "查询 Hyper3D 模型任务状态失败", + ) + .await?; + + let jobs = extract_job_statuses(&response); + let status = resolve_hyper3d_overall_status(&response, &jobs); + + Ok(contract::Hyper3dTaskStatusResponse { + ok: true, + provider: HYPER3D_PROVIDER.to_string(), + status, + jobs, + raw: response, + }) +} + +pub async fn query_downloads( + state: &Hyper3dSettings, + payload: contract::Hyper3dDownloadRequest, +) -> Result { + let http_client = build_hyper3d_http_client(state)?; + let task_uuid = normalize_required_text(&payload.task_uuid, "taskUuid", 256)?; + let response = post_hyper3d_json( + &http_client, + state, + "/download", + json!({ "task_uuid": task_uuid }), + "获取 Hyper3D 模型下载列表失败", + ) + .await?; + + Ok(contract::Hyper3dDownloadResponse { + ok: true, + provider: HYPER3D_PROVIDER.to_string(), + files: extract_download_files(&response), + raw: response, + }) +} diff --git a/server-rs/crates/platform-hyper3d/src/error.rs b/server-rs/crates/platform-hyper3d/src/error.rs new file mode 100644 index 00000000..6af40fc8 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/error.rs @@ -0,0 +1,180 @@ +use std::{error::Error, fmt}; + +use crate::HYPER3D_PROVIDER; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Hyper3dStatusHint { + BadRequest, + ServiceUnavailable, + BadGateway, + GatewayTimeout, +} + +#[derive(Clone, Debug)] +pub enum Hyper3dError { + InvalidConfig { + provider: &'static str, + reason: Option<&'static str>, + message: String, + }, + InvalidRequest { + provider: &'static str, + field: Option<&'static str>, + message: String, + allowed: Option>, + }, + Request { + provider: &'static str, + message: String, + endpoint: Option, + timeout: bool, + connect: bool, + request: bool, + body: bool, + status_code: Option, + source: Option, + }, + Upstream { + provider: &'static str, + message: String, + upstream_status: u16, + raw_excerpt: String, + }, + ResponseParse { + provider: &'static str, + message: String, + raw_excerpt: String, + }, + MissingField { + provider: &'static str, + message: String, + }, +} + +impl Hyper3dError { + pub fn provider(&self) -> &'static str { + match self { + Self::InvalidConfig { provider, .. } + | Self::InvalidRequest { provider, .. } + | Self::Request { provider, .. } + | Self::Upstream { provider, .. } + | Self::ResponseParse { provider, .. } + | Self::MissingField { provider, .. } => provider, + } + } + + pub fn message(&self) -> &str { + match self { + Self::InvalidConfig { message, .. } + | Self::InvalidRequest { message, .. } + | Self::Request { message, .. } + | Self::Upstream { message, .. } + | Self::ResponseParse { message, .. } + | Self::MissingField { message, .. } => message, + } + } + + pub fn status_hint(&self) -> Hyper3dStatusHint { + match self { + Self::InvalidConfig { .. } => Hyper3dStatusHint::ServiceUnavailable, + Self::InvalidRequest { .. } => Hyper3dStatusHint::BadRequest, + Self::Request { timeout, .. } if *timeout => Hyper3dStatusHint::GatewayTimeout, + Self::Request { .. } + | Self::Upstream { .. } + | Self::ResponseParse { .. } + | Self::MissingField { .. } => Hyper3dStatusHint::BadGateway, + } + } + + pub fn invalid_config(reason: &'static str, message: impl Into) -> Self { + Self::InvalidConfig { + provider: HYPER3D_PROVIDER, + reason: Some(reason), + message: message.into(), + } + } + + pub(crate) fn invalid_request(field: Option<&'static str>, message: impl Into) -> Self { + Self::InvalidRequest { + provider: HYPER3D_PROVIDER, + field, + message: message.into(), + allowed: None, + } + } + + pub(crate) fn invalid_request_allowed( + field: &'static str, + message: impl Into, + allowed: &[&str], + ) -> Self { + Self::InvalidRequest { + provider: HYPER3D_PROVIDER, + field: Some(field), + message: message.into(), + allowed: Some(allowed.iter().map(|value| value.to_string()).collect()), + } + } + + pub(crate) fn request( + message: impl Into, + endpoint: Option, + timeout: bool, + connect: bool, + request: bool, + body: bool, + status_code: Option, + source: Option, + ) -> Self { + Self::Request { + provider: HYPER3D_PROVIDER, + message: message.into(), + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + } + } + + pub(crate) fn upstream( + message: impl Into, + upstream_status: u16, + raw_excerpt: impl Into, + ) -> Self { + Self::Upstream { + provider: HYPER3D_PROVIDER, + message: message.into(), + upstream_status, + raw_excerpt: raw_excerpt.into(), + } + } + + pub(crate) fn response_parse( + message: impl Into, + raw_excerpt: impl Into, + ) -> Self { + Self::ResponseParse { + provider: HYPER3D_PROVIDER, + message: message.into(), + raw_excerpt: raw_excerpt.into(), + } + } + + pub(crate) fn missing_field(message: impl Into) -> Self { + Self::MissingField { + provider: HYPER3D_PROVIDER, + message: message.into(), + } + } +} + +impl fmt::Display for Hyper3dError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message()) + } +} + +impl Error for Hyper3dError {} diff --git a/server-rs/crates/platform-hyper3d/src/lib.rs b/server-rs/crates/platform-hyper3d/src/lib.rs new file mode 100644 index 00000000..39e49d34 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/lib.rs @@ -0,0 +1,13 @@ +mod client; +mod error; +mod request; +mod response; +mod transport; +mod types; + +pub use client::{ + build_hyper3d_http_client, query_downloads, query_task_status, submit_image_to_model, + submit_text_to_model, +}; +pub use error::{Hyper3dError, Hyper3dStatusHint}; +pub use types::{HYPER3D_PROVIDER, Hyper3dSettings}; diff --git a/server-rs/crates/platform-hyper3d/src/request.rs b/server-rs/crates/platform-hyper3d/src/request.rs new file mode 100644 index 00000000..7842a0aa --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/request.rs @@ -0,0 +1,14 @@ +mod image_data_url; +mod normalize; +mod options; +#[cfg(test)] +mod tests; + +pub(crate) use image_data_url::decode_image_data_urls; +pub(crate) use normalize::{ + normalize_condition_mode, normalize_optional_limited_text, normalize_required_opaque_text, + normalize_required_text, +}; +pub(crate) use options::{ + build_common_submit_fields, build_submit_options_from_image, build_submit_options_from_text, +}; diff --git a/server-rs/crates/platform-hyper3d/src/request/image_data_url.rs b/server-rs/crates/platform-hyper3d/src/request/image_data_url.rs new file mode 100644 index 00000000..c75537ce --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/request/image_data_url.rs @@ -0,0 +1,61 @@ +use base64::Engine as _; + +use crate::{ + error::Hyper3dError, + types::{DecodedImageDataUrl, MAX_IMAGE_BYTES}, +}; + +pub(crate) fn decode_image_data_urls( + values: &[String], +) -> Result, Hyper3dError> { + values + .iter() + .enumerate() + .map(|(index, value)| decode_image_data_url(value, index + 1)) + .collect() +} + +pub(crate) fn decode_image_data_url( + value: &str, + index: usize, +) -> Result { + let value = value.trim(); + let Some((metadata, encoded)) = value.split_once(',') else { + return Err(invalid_image_data_url("参考图必须是 data URL")); + }; + if !metadata.starts_with("data:image/") || !metadata.ends_with(";base64") { + return Err(invalid_image_data_url( + "参考图只支持 image/png、image/jpeg 或 image/webp 的 base64 data URL", + )); + } + let mime_type = metadata + .trim_start_matches("data:") + .trim_end_matches(";base64") + .to_string(); + let extension = match mime_type.as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/webp" => "webp", + _ => { + return Err(invalid_image_data_url( + "参考图只支持 image/png、image/jpeg 或 image/webp", + )); + } + }; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| invalid_image_data_url("参考图 base64 解码失败"))?; + if bytes.is_empty() || bytes.len() > MAX_IMAGE_BYTES { + return Err(invalid_image_data_url("参考图为空或超过 10MB")); + } + + Ok(DecodedImageDataUrl { + bytes, + mime_type, + file_name: format!("reference-{index:02}.{extension}"), + }) +} + +fn invalid_image_data_url(message: &str) -> Hyper3dError { + Hyper3dError::invalid_request(Some("imageDataUrls"), message.to_string()) +} diff --git a/server-rs/crates/platform-hyper3d/src/request/normalize.rs b/server-rs/crates/platform-hyper3d/src/request/normalize.rs new file mode 100644 index 00000000..8f543b95 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/request/normalize.rs @@ -0,0 +1,119 @@ +use crate::{error::Hyper3dError, types::DEFAULT_CONDITION_MODE}; + +pub(crate) fn normalize_required_text( + value: &str, + field: &'static str, + max_chars: usize, +) -> Result { + let normalized = value.trim().to_string(); + if normalized.is_empty() { + return Err(Hyper3dError::invalid_request( + Some(field), + format!("{field} 不能为空"), + )); + } + if normalized.chars().count() > max_chars { + return Err(Hyper3dError::invalid_request( + Some(field), + format!("{field} 超过 {} 字符", max_chars), + )); + } + Ok(normalized) +} + +pub(crate) fn normalize_optional_limited_text( + value: Option<&str>, + max_chars: usize, +) -> Result, Hyper3dError> { + let Some(normalized) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if normalized.chars().count() > max_chars { + return Err(Hyper3dError::invalid_request( + None, + format!("文本超过 {} 字符", max_chars), + )); + } + Ok(Some(normalized.to_string())) +} + +pub(crate) fn normalize_required_opaque_text( + value: &str, + field: &'static str, +) -> Result { + let normalized = value.trim().to_string(); + if normalized.is_empty() { + return Err(Hyper3dError::invalid_request( + Some(field), + format!("{field} 不能为空"), + )); + } + Ok(normalized) +} + +pub(crate) fn normalize_enum( + value: Option<&str>, + default_value: &str, + allowed_values: &[&str], + field: &'static str, +) -> Result { + let value = value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(default_value); + if let Some(allowed) = allowed_values + .iter() + .find(|allowed| allowed.eq_ignore_ascii_case(value)) + { + return Ok((*allowed).to_string()); + } + Err(Hyper3dError::invalid_request_allowed( + field, + format!("{} 取值非法", field), + allowed_values, + )) +} + +pub(crate) fn normalize_condition_mode(value: Option<&str>) -> Result { + normalize_enum( + value, + DEFAULT_CONDITION_MODE, + &["concat", "fuse"], + "conditionMode", + ) +} + +pub(crate) fn normalize_addons(values: Vec) -> Result, Hyper3dError> { + let mut addons = Vec::new(); + for value in values { + let value = value.trim(); + if value.is_empty() { + continue; + } + if value != "HighPack" { + return Err(Hyper3dError::invalid_request( + Some("addons"), + "addons 首版只支持 HighPack", + )); + } + if !addons.iter().any(|addon| addon == value) { + addons.push(value.to_string()); + } + } + Ok(addons) +} + +pub(crate) fn normalize_bbox_condition( + value: Option>, +) -> Result>, Hyper3dError> { + let Some(value) = value else { + return Ok(None); + }; + if value.len() != 3 || value.iter().any(|item| !item.is_finite() || *item <= 0.0) { + return Err(Hyper3dError::invalid_request( + Some("bboxCondition"), + "bboxCondition 必须包含 3 个正数", + )); + } + Ok(Some(value)) +} diff --git a/server-rs/crates/platform-hyper3d/src/request/options.rs b/server-rs/crates/platform-hyper3d/src/request/options.rs new file mode 100644 index 00000000..caa04ad8 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/request/options.rs @@ -0,0 +1,111 @@ +use crate::{ + error::Hyper3dError, + request::normalize::{normalize_addons, normalize_bbox_condition, normalize_enum}, + types::{ + DEFAULT_GEOMETRY_FILE_FORMAT, DEFAULT_MATERIAL, DEFAULT_MESH_MODE, DEFAULT_QUALITY, + SubmitOptions, + }, +}; + +pub(crate) fn build_submit_options_from_text( + payload: &shared_contracts::hyper3d::Hyper3dTextToModelRequest, +) -> Result { + SubmitOptions::new( + payload.seed, + payload.geometry_file_format.as_deref(), + payload.material.as_deref(), + payload.quality.as_deref(), + payload.mesh_mode.as_deref(), + payload.addons.clone(), + payload.bbox_condition.clone(), + payload.preview_render, + ) +} + +pub(crate) fn build_submit_options_from_image( + payload: &shared_contracts::hyper3d::Hyper3dImageToModelRequest, +) -> Result { + SubmitOptions::new( + payload.seed, + payload.geometry_file_format.as_deref(), + payload.material.as_deref(), + payload.quality.as_deref(), + payload.mesh_mode.as_deref(), + payload.addons.clone(), + payload.bbox_condition.clone(), + payload.preview_render, + ) +} + +impl SubmitOptions { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + seed: Option, + geometry_file_format: Option<&str>, + material: Option<&str>, + quality: Option<&str>, + mesh_mode: Option<&str>, + addons: Vec, + bbox_condition: Option>, + preview_render: Option, + ) -> Result { + Ok(Self { + seed, + geometry_file_format: normalize_enum( + geometry_file_format, + DEFAULT_GEOMETRY_FILE_FORMAT, + &["glb", "usdz", "fbx", "obj", "stl"], + "geometryFileFormat", + )?, + material: normalize_enum( + material, + DEFAULT_MATERIAL, + &["PBR", "Shaded", "All"], + "material", + )?, + quality: normalize_enum( + quality, + DEFAULT_QUALITY, + &["high", "medium", "low", "extra-low"], + "quality", + )?, + mesh_mode: normalize_enum(mesh_mode, DEFAULT_MESH_MODE, &["Quad", "Raw"], "meshMode")?, + addons: normalize_addons(addons)?, + bbox_condition: normalize_bbox_condition(bbox_condition)?, + preview_render: preview_render.unwrap_or(true), + }) + } +} + +pub(crate) fn build_common_submit_fields( + form: reqwest::multipart::Form, + options: &SubmitOptions, +) -> Result { + let mut form = form + .text( + "geometry_file_format", + options.geometry_file_format.to_string(), + ) + .text("material", options.material.to_string()) + .text("quality", options.quality.to_string()) + .text("mesh_mode", options.mesh_mode.to_string()) + .text("preview_render", options.preview_render.to_string()); + if let Some(seed) = options.seed { + form = form.text("seed", seed.to_string()); + } + for addon in &options.addons { + form = form.text("addons", addon.to_string()); + } + if let Some(bbox_condition) = &options.bbox_condition { + form = form.text( + "bbox_condition", + serde_json::to_string(bbox_condition).map_err(|error| { + Hyper3dError::invalid_request( + Some("bboxCondition"), + format!("bboxCondition 序列化失败:{error}"), + ) + })?, + ); + } + Ok(form) +} diff --git a/server-rs/crates/platform-hyper3d/src/request/tests.rs b/server-rs/crates/platform-hyper3d/src/request/tests.rs new file mode 100644 index 00000000..9dd61e8f --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/request/tests.rs @@ -0,0 +1,64 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use shared_contracts::hyper3d as contract; + +use super::{ + image_data_url::decode_image_data_url, + normalize::{normalize_bbox_condition, normalize_required_opaque_text}, + options::build_submit_options_from_text, +}; + +#[test] +fn validates_and_defaults_submit_options() { + let payload = contract::Hyper3dTextToModelRequest { + prompt: "宝箱".to_string(), + negative_prompt: None, + seed: Some(7), + geometry_file_format: None, + material: None, + quality: None, + mesh_mode: None, + addons: vec!["HighPack".to_string()], + bbox_condition: Some(vec![1.0, 2.0, 3.0]), + preview_render: None, + }; + + let options = build_submit_options_from_text(&payload).expect("options should build"); + + assert_eq!(options.geometry_file_format, "glb"); + assert_eq!(options.material, "PBR"); + assert_eq!(options.quality, "medium"); + assert_eq!(options.mesh_mode, "Quad"); + assert_eq!(options.addons, vec!["HighPack"]); + assert!(options.preview_render); +} + +#[test] +fn rejects_invalid_bbox_condition() { + let error = + normalize_bbox_condition(Some(vec![1.0, 0.0, 3.0])).expect_err("invalid bbox should fail"); + + assert_eq!(error.status_hint(), crate::Hyper3dStatusHint::BadRequest); +} + +#[test] +fn accepts_opaque_subscription_key_without_length_cap() { + let long_key = "a".repeat(300); + let normalized = normalize_required_opaque_text(&format!(" {long_key} "), "subscriptionKey") + .expect("subscription key should be accepted"); + + assert_eq!(normalized, long_key); +} + +#[test] +fn decodes_png_data_url() { + let data_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") + ); + + let image = decode_image_data_url(&data_url, 1).expect("image should decode"); + + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.file_name, "reference-01.png"); + assert!(!image.bytes.is_empty()); +} diff --git a/server-rs/crates/platform-hyper3d/src/response.rs b/server-rs/crates/platform-hyper3d/src/response.rs new file mode 100644 index 00000000..bc8d5a07 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/response.rs @@ -0,0 +1,11 @@ +mod downloads; +mod parsing; +mod status; +mod submit; +#[cfg(test)] +mod tests; + +pub(crate) use downloads::extract_download_files; +pub(crate) use parsing::parse_api_error_message; +pub(crate) use status::{extract_job_statuses, resolve_hyper3d_overall_status}; +pub(crate) use submit::build_submit_response; diff --git a/server-rs/crates/platform-hyper3d/src/response/downloads.rs b/server-rs/crates/platform-hyper3d/src/response/downloads.rs new file mode 100644 index 00000000..4533dfb9 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/response/downloads.rs @@ -0,0 +1,67 @@ +use serde_json::Value; + +pub(crate) fn extract_download_files( + payload: &Value, +) -> Vec { + let mut files = Vec::new(); + collect_download_files(payload, &mut files); + let mut deduped = Vec::new(); + for file in files { + if !deduped.iter().any( + |entry: &shared_contracts::hyper3d::Hyper3dDownloadFilePayload| entry.url == file.url, + ) { + deduped.push(file); + } + } + deduped +} + +fn collect_download_files( + value: &Value, + output: &mut Vec, +) { + match value { + Value::Object(object) => { + let maybe_url = object + .get("url") + .or_else(|| object.get("download_url")) + .or_else(|| object.get("downloadUrl")) + .or_else(|| object.get("file_url")) + .or_else(|| object.get("fileUrl")) + .or_else(|| object.get("signed_url")) + .or_else(|| object.get("signedUrl")) + .or_else(|| object.get("presigned_url")) + .or_else(|| object.get("presignedUrl")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| value.starts_with("http://") || value.starts_with("https://")); + if let Some(url) = maybe_url { + let name = object + .get("name") + .or_else(|| object.get("file_name")) + .or_else(|| object.get("filename")) + .or_else(|| object.get("fileName")) + .or_else(|| object.get("display_name")) + .or_else(|| object.get("displayName")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("model") + .to_string(); + output.push(shared_contracts::hyper3d::Hyper3dDownloadFilePayload { + name, + url: url.to_string(), + }); + } + for nested in object.values() { + collect_download_files(nested, output); + } + } + Value::Array(items) => { + for item in items { + collect_download_files(item, output); + } + } + _ => {} + } +} diff --git a/server-rs/crates/platform-hyper3d/src/response/parsing.rs b/server-rs/crates/platform-hyper3d/src/response/parsing.rs new file mode 100644 index 00000000..319e8416 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/response/parsing.rs @@ -0,0 +1,159 @@ +use serde_json::Value; + +pub(crate) fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { + if let Ok(parsed) = serde_json::from_str::(raw_text) { + for key in ["message", "detail", "error"] { + if let Some(message) = find_first_string_by_key(&parsed, key) + && !message.trim().is_empty() + { + return message; + } + } + } + raw_text + .trim() + .chars() + .take(240) + .collect::() + .trim() + .to_string() + .chars() + .next() + .map(|_| raw_text.trim().chars().take(240).collect()) + .unwrap_or_else(|| fallback_message.to_string()) +} + +pub(crate) fn find_first_array_by_keys<'a>( + value: &'a Value, + keys: &[&str], +) -> Option<&'a Vec> { + match value { + Value::Object(object) => { + for (key, value) in object { + if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) + && let Some(array) = value.as_array() + { + return Some(array); + } + if let Some(found) = find_first_array_by_keys(value, keys) { + return Some(found); + } + } + None + } + Value::Array(items) => items + .iter() + .find_map(|item| find_first_array_by_keys(item, keys)), + _ => None, + } +} + +pub(crate) fn find_first_string_by_keys(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| find_first_string_by_key(value, key)) +} + +pub(crate) fn find_first_f64_by_keys(value: &Value, keys: &[&str]) -> Option { + match value { + Value::Object(object) => { + for (key, value) in object { + if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) + && let Some(number) = value.as_f64() + { + return Some(number); + } + if let Some(found) = find_first_f64_by_keys(value, keys) { + return Some(found); + } + } + None + } + Value::Array(items) => items + .iter() + .find_map(|item| find_first_f64_by_keys(item, keys)), + _ => None, + } +} + +pub(crate) fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + match value { + Value::Object(object) => { + for (key, value) in object { + if key.eq_ignore_ascii_case(target_key) + && let Some(text) = value.as_str() + { + return Some(text.trim().to_string()); + } + if let Some(found) = find_first_string_by_key(value, target_key) { + return Some(found); + } + } + None + } + Value::Array(items) => items + .iter() + .find_map(|item| find_first_string_by_key(item, target_key)), + _ => None, + } +} + +pub(crate) fn find_root_string_by_keys(value: &Value, keys: &[&str]) -> Option { + let object = value.as_object()?; + for key in keys { + if let Some(text) = object + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key)) + .and_then(|(_, value)| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(text.to_string()); + } + } + None +} + +pub(crate) fn collect_strings_by_keys(value: &Value, keys: &[&str]) -> Vec { + let mut results = Vec::new(); + collect_strings(value, keys, &mut results); + let mut deduped = Vec::new(); + for result in results { + if !deduped.contains(&result) { + deduped.push(result); + } + } + deduped +} + +fn collect_strings(value: &Value, keys: &[&str], output: &mut Vec) { + match value { + Value::Object(object) => { + for (key, value) in object { + if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) { + match value { + Value::String(text) if !text.trim().is_empty() => { + output.push(text.trim().to_string()); + } + Value::Array(items) => { + for item in items { + if let Some(text) = item.as_str().map(str::trim) + && !text.is_empty() + { + output.push(text.to_string()); + } + } + } + _ => {} + } + } + collect_strings(value, keys, output); + } + } + Value::Array(items) => { + for item in items { + collect_strings(item, keys, output); + } + } + _ => {} + } +} diff --git a/server-rs/crates/platform-hyper3d/src/response/status.rs b/server-rs/crates/platform-hyper3d/src/response/status.rs new file mode 100644 index 00000000..32ed4431 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/response/status.rs @@ -0,0 +1,69 @@ +use serde_json::Value; + +pub(crate) fn normalize_task_status(status: &str) -> String { + match status.trim().to_ascii_lowercase().as_str() { + "waiting" | "pending" | "queued" => "waiting".to_string(), + "generating" | "running" | "processing" => "generating".to_string(), + "done" | "finished" | "completed" | "success" | "succeeded" => "done".to_string(), + "failed" | "error" | "canceled" | "cancelled" => "failed".to_string(), + _ => "unknown".to_string(), + } +} + +pub(crate) fn extract_job_statuses( + payload: &Value, +) -> Vec { + let Some(array) = super::parsing::find_first_array_by_keys(payload, &["jobs", "tasks"]) else { + return Vec::new(); + }; + + array + .iter() + .filter_map(|value| { + let status = super::parsing::find_first_string_by_keys(value, &["status", "state"]) + .map(|value| normalize_task_status(&value))?; + Some(shared_contracts::hyper3d::Hyper3dJobStatusPayload { + uuid: super::parsing::find_first_string_by_keys( + value, + &["uuid", "task_uuid", "taskUuid"], + ), + progress: super::parsing::find_first_f64_by_keys( + value, + &["progress", "percentage"], + ) + .map(|value| value as f32), + message: super::parsing::find_first_string_by_keys( + value, + &["message", "detail", "error"], + ), + status, + }) + }) + .collect() +} + +pub(crate) fn resolve_hyper3d_overall_status( + payload: &Value, + jobs: &[shared_contracts::hyper3d::Hyper3dJobStatusPayload], +) -> String { + if !jobs.is_empty() { + if jobs.iter().any(|job| job.status == "failed") { + return "failed".to_string(); + } + if jobs.iter().all(|job| job.status == "done") { + return "done".to_string(); + } + if jobs.iter().any(|job| job.status == "generating") { + return "generating".to_string(); + } + if jobs.iter().any(|job| job.status == "waiting") { + return "waiting".to_string(); + } + return "unknown".to_string(); + } + normalize_task_status( + super::parsing::find_first_string_by_key(payload, "status") + .as_deref() + .unwrap_or("unknown"), + ) +} diff --git a/server-rs/crates/platform-hyper3d/src/response/submit.rs b/server-rs/crates/platform-hyper3d/src/response/submit.rs new file mode 100644 index 00000000..90fd76a9 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/response/submit.rs @@ -0,0 +1,64 @@ +use serde_json::Value; + +use crate::{ + error::Hyper3dError, + types::{HYPER3D_PROVIDER, RODIN_GEN2_TIER}, +}; + +pub(crate) fn build_submit_response( + mode: shared_contracts::hyper3d::Hyper3dGenerationMode, + response: Value, +) -> Result { + let task_uuid = + super::parsing::find_root_string_by_keys(&response, &["uuid", "task_uuid", "taskUuid"]) + .or_else(|| { + super::parsing::find_first_string_by_keys(&response, &["task_uuid", "taskUuid"]) + }) + .ok_or_else(|| Hyper3dError::missing_field("Hyper3D 已响应,但未返回任务 uuid"))?; + let subscription_key = super::parsing::find_root_string_by_keys( + &response, + &["subscription_key", "subscriptionKey"], + ) + .or_else(|| { + super::parsing::find_first_string_by_keys( + &response, + &["subscription_key", "subscriptionKey"], + ) + }) + .ok_or_else(|| Hyper3dError::missing_field("Hyper3D 已响应,但未返回 subscription_key"))?; + let job_uuids = extract_job_uuids(&response); + let message = super::parsing::find_first_string_by_keys(&response, &["message", "detail"]); + + Ok(shared_contracts::hyper3d::Hyper3dTaskSubmitResponse { + ok: true, + provider: HYPER3D_PROVIDER.to_string(), + mode, + task_uuid, + subscription_key, + job_uuids, + message, + tier: RODIN_GEN2_TIER.to_string(), + }) +} + +fn extract_job_uuids(payload: &Value) -> Vec { + let mut job_uuids = Vec::new(); + if let Some(jobs) = payload.get("jobs") { + for uuid in super::parsing::collect_strings_by_keys( + jobs, + &["uuid", "task_uuid", "taskUuid", "uuids"], + ) { + if !job_uuids.contains(&uuid) { + job_uuids.push(uuid); + } + } + } + for uuid in + super::parsing::collect_strings_by_keys(payload, &["job_uuids", "jobUuids", "uuids"]) + { + if !job_uuids.contains(&uuid) { + job_uuids.push(uuid); + } + } + job_uuids +} diff --git a/server-rs/crates/platform-hyper3d/src/response/tests.rs b/server-rs/crates/platform-hyper3d/src/response/tests.rs new file mode 100644 index 00000000..e93b1b94 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/response/tests.rs @@ -0,0 +1,88 @@ +use serde_json::json; +use shared_contracts::hyper3d as contract; + +use super::{ + build_submit_response, extract_download_files, extract_job_statuses, + resolve_hyper3d_overall_status, +}; +use super::status::normalize_task_status; + +#[test] +fn extracts_submit_response_from_nested_payload() { + let response = build_submit_response( + contract::Hyper3dGenerationMode::TextToModel, + json!({ + "uuid": "task-1", + "jobs": { + "uuids": ["job-1", "job-2"], + "subscription_key": "sub-1" + }, + "message": "submitted" + }), + ) + .expect("submit response should build"); + + assert_eq!(response.task_uuid, "task-1"); + assert_eq!(response.subscription_key, "sub-1"); + assert_eq!(response.job_uuids, vec!["job-1", "job-2"]); +} + +#[test] +fn extracts_download_files_from_file_url_aliases() { + let files = extract_download_files(&json!({ + "result": { + "files": [ + { + "fileName": "rodin-result.glb", + "fileUrl": "https://cdn.example/rodin-result.glb?token=1" + }, + { + "displayName": "preview.png", + "signedUrl": "https://cdn.example/preview.png?token=1" + } + ] + } + })); + + assert_eq!(files.len(), 2); + assert_eq!(files[0].name, "rodin-result.glb"); + assert_eq!(files[0].url, "https://cdn.example/rodin-result.glb?token=1"); +} + +#[test] +fn normalizes_status_values() { + assert_eq!(normalize_task_status("Waiting"), "waiting"); + assert_eq!(normalize_task_status("Generating"), "generating"); + assert_eq!(normalize_task_status("Done"), "done"); + assert_eq!(normalize_task_status("Failed"), "failed"); +} + +#[test] +fn resolves_status_done_only_when_all_jobs_done() { + let jobs = extract_job_statuses(&json!({ + "jobs": [ + { "uuid": "preview", "status": "Done" }, + { "uuid": "model", "status": "Generating" } + ] + })); + + assert_eq!( + resolve_hyper3d_overall_status(&json!({ "status": "Done" }), &jobs), + "generating" + ); +} + +#[test] +fn resolves_status_failed_when_any_job_failed() { + let jobs = extract_job_statuses(&json!({ + "jobs": [ + { "uuid": "preview", "status": "Done" }, + { "uuid": "model", "status": "Failed", "message": "bad input" } + ] + })); + + assert_eq!( + resolve_hyper3d_overall_status(&json!({ "status": "Generating" }), &jobs), + "failed" + ); +} diff --git a/server-rs/crates/platform-hyper3d/src/transport.rs b/server-rs/crates/platform-hyper3d/src/transport.rs new file mode 100644 index 00000000..4a98c0cc --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/transport.rs @@ -0,0 +1,99 @@ +use std::error::Error; + +use reqwest::header; +use serde_json::Value; + +use crate::{error::Hyper3dError, response::parse_api_error_message, types::Hyper3dSettings}; + +pub(crate) async fn post_hyper3d_multipart( + http_client: &reqwest::Client, + settings: &Hyper3dSettings, + path: &str, + form: reqwest::multipart::Form, + failure_context: &str, +) -> Result { + let response = http_client + .post(format!("{}{}", settings.base_url, path)) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .multipart(form) + .send() + .await + .map_err(|error| map_reqwest_error(failure_context, path, error))?; + parse_hyper3d_response(response, failure_context).await +} + +pub(crate) async fn post_hyper3d_json( + http_client: &reqwest::Client, + settings: &Hyper3dSettings, + path: &str, + body: Value, + failure_context: &str, +) -> Result { + let response = http_client + .post(format!("{}{}", settings.base_url, path)) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json") + .json(&body) + .send() + .await + .map_err(|error| map_reqwest_error(failure_context, path, error))?; + parse_hyper3d_response(response, failure_context).await +} + +async fn parse_hyper3d_response( + response: reqwest::Response, + failure_context: &str, +) -> Result { + let status = response.status(); + let raw_text = response.text().await.map_err(|error| { + Hyper3dError::request( + format!("{failure_context}:读取上游响应失败:{error}"), + None, + false, + false, + false, + true, + None, + None, + ) + })?; + if !status.is_success() { + return Err(Hyper3dError::upstream( + parse_api_error_message(&raw_text, failure_context), + status.as_u16(), + truncate_raw(&raw_text), + )); + } + + serde_json::from_str::(&raw_text).map_err(|error| { + Hyper3dError::response_parse( + format!("{failure_context}:解析上游 JSON 失败:{error}"), + truncate_raw(&raw_text), + ) + }) +} + +fn map_reqwest_error(failure_context: &str, endpoint: &str, error: reqwest::Error) -> Hyper3dError { + Hyper3dError::request( + format!("{failure_context}:{error}"), + Some(endpoint.to_string()), + error.is_timeout(), + error.is_connect(), + error.is_request(), + error.is_body(), + error.status().map(|status| status.as_u16()), + Error::source(&error).map(ToString::to_string), + ) +} + +fn truncate_raw(raw_text: &str) -> String { + raw_text.chars().take(800).collect() +} diff --git a/server-rs/crates/platform-hyper3d/src/types.rs b/server-rs/crates/platform-hyper3d/src/types.rs new file mode 100644 index 00000000..50b2b93b --- /dev/null +++ b/server-rs/crates/platform-hyper3d/src/types.rs @@ -0,0 +1,37 @@ +#[derive(Clone, Debug)] +pub struct Hyper3dSettings { + pub base_url: String, + pub api_key: String, + pub request_timeout_ms: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct DecodedImageDataUrl { + pub(crate) bytes: Vec, + pub(crate) mime_type: String, + pub(crate) file_name: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct SubmitOptions { + pub(crate) seed: Option, + pub(crate) geometry_file_format: String, + pub(crate) material: String, + pub(crate) quality: String, + pub(crate) mesh_mode: String, + pub(crate) addons: Vec, + pub(crate) bbox_condition: Option>, + pub(crate) preview_render: bool, +} + +pub const HYPER3D_PROVIDER: &str = "hyper3d-rodin"; +pub(crate) const RODIN_GEN2_TIER: &str = "Gen-2"; +pub(crate) const DEFAULT_GEOMETRY_FILE_FORMAT: &str = "glb"; +pub(crate) const DEFAULT_MATERIAL: &str = "PBR"; +pub(crate) const DEFAULT_QUALITY: &str = "medium"; +pub(crate) const DEFAULT_MESH_MODE: &str = "Quad"; +pub(crate) const DEFAULT_CONDITION_MODE: &str = "concat"; +pub(crate) const MAX_PROMPT_CHARS: usize = 2_000; +pub(crate) const MAX_NEGATIVE_PROMPT_CHARS: usize = 1_000; +pub(crate) const MAX_IMAGE_COUNT: usize = 5; +pub(crate) const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; diff --git a/server-rs/crates/platform-hyper3d/tests/hyper3d_rodin.rs b/server-rs/crates/platform-hyper3d/tests/hyper3d_rodin.rs new file mode 100644 index 00000000..0802b980 --- /dev/null +++ b/server-rs/crates/platform-hyper3d/tests/hyper3d_rodin.rs @@ -0,0 +1,61 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use serde_json::json; +use shared_contracts::hyper3d as contract; + +#[test] +fn submits_response_contract_keeps_kebab_case_mode() { + let payload = serde_json::to_value(contract::Hyper3dTaskSubmitResponse { + ok: true, + provider: "hyper3d-rodin".to_string(), + mode: contract::Hyper3dGenerationMode::ImageToModel, + task_uuid: "task-1".to_string(), + subscription_key: "sub-1".to_string(), + job_uuids: vec!["job-1".to_string()], + message: Some("submitted".to_string()), + tier: "Gen-2".to_string(), + }) + .expect("response should serialize"); + + assert_eq!(payload["mode"], json!("image-to-model")); + assert_eq!(payload["subscriptionKey"], json!("sub-1")); +} + +#[tokio::test] +async fn image_data_url_limit_error_is_bad_request() { + let payload = contract::Hyper3dImageToModelRequest { + image_data_urls: vec![ + format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") + ); + 6 + ], + image_urls: Vec::new(), + prompt: None, + condition_mode: None, + seed: None, + geometry_file_format: None, + material: None, + quality: None, + mesh_mode: None, + addons: Vec::new(), + bbox_condition: None, + preview_render: None, + }; + + let error = platform_hyper3d::submit_image_to_model( + &platform_hyper3d::Hyper3dSettings { + base_url: "https://invalid.local".to_string(), + api_key: "test".to_string(), + request_timeout_ms: 1, + }, + payload, + ) + .await + .expect_err("too many images should fail before request"); + + assert_eq!( + error.status_hint(), + platform_hyper3d::Hyper3dStatusHint::BadRequest + ); +} diff --git a/server-rs/crates/platform-image/Cargo.toml b/server-rs/crates/platform-image/Cargo.toml index cafad647..f71fe161 100644 --- a/server-rs/crates/platform-image/Cargo.toml +++ b/server-rs/crates/platform-image/Cargo.toml @@ -6,7 +6,9 @@ license.workspace = true [dependencies] base64 = { workspace = true } +image = { workspace = true, features = ["jpeg", "png", "webp"] } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["time"] } tracing = { workspace = true } +platform-oss = { workspace = true } diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs new file mode 100644 index 00000000..92810d15 --- /dev/null +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs @@ -0,0 +1,429 @@ +use super::color::{ + GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE, + GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit, + compute_generated_asset_sheet_green_screen_score, + compute_generated_asset_sheet_white_screen_score, + is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel, + touches_generated_asset_sheet_background_mask, +}; + +pub fn apply_generated_asset_sheet_green_screen_alpha( + source: image::DynamicImage, +) -> image::DynamicImage { + let mut image = source.to_rgba8(); + let (width, height) = image.dimensions(); + remove_generated_asset_sheet_green_screen_background( + image.as_mut(), + width as usize, + height as usize, + ); + image::DynamicImage::ImageRgba8(image) +} + +fn remove_generated_asset_sheet_green_screen_background( + pixels: &mut [u8], + width: usize, + height: usize, +) -> bool { + let pixel_count = width.saturating_mul(height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let mut green_scores = vec![0.0f32; pixel_count]; + let mut white_scores = vec![0.0f32; pixel_count]; + let mut background_hints = vec![0.0f32; pixel_count]; + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + let red = pixels[offset]; + let green = pixels[offset + 1]; + let blue = pixels[offset + 2]; + let alpha = pixels[offset + 3]; + let green_score = + compute_generated_asset_sheet_green_screen_score([red, green, blue, alpha]); + let white_score = + compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]); + let transparency_hint = + clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75; + + green_scores[pixel_index] = green_score; + white_scores[pixel_index] = white_score; + background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint); + } + + let seed_background_pixel = + |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { + if background_mask[pixel_index] != 0 { + return; + } + let alpha = pixels[pixel_index * 4 + 3]; + let strong_candidate = alpha < 40 + || green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + || (alpha < 224 + && green_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) + || white_scores[pixel_index] > 0.32; + if !strong_candidate { + return; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + }; + + for x in 0..width { + seed_background_pixel(x, &mut background_mask, &mut queue); + seed_background_pixel((height - 1) * width + x, &mut background_mask, &mut queue); + } + for y in 1..height.saturating_sub(1) { + seed_background_pixel(y * width, &mut background_mask, &mut queue); + seed_background_pixel(y * width + width - 1, &mut background_mask, &mut queue); + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + + let x = pixel_index % width; + let y = pixel_index / width; + let neighbor_indexes = [ + if x > 0 { Some(pixel_index - 1) } else { None }, + if x + 1 < width { + Some(pixel_index + 1) + } else { + None + }, + if y > 0 { + Some(pixel_index - width) + } else { + None + }, + if y + 1 < height { + Some(pixel_index + width) + } else { + None + }, + ]; + + for next_pixel_index in neighbor_indexes.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let next_offset = next_pixel_index * 4; + let alpha = pixels[next_offset + 3]; + let green_score = green_scores[next_pixel_index]; + let white_score = white_scores[next_pixel_index]; + let hint = background_hints[next_pixel_index]; + let reachable_soft_edge = hint > 0.08 + && alpha < 224 + && (green_score > 0.04 || white_score > 0.08 || alpha < 180); + let green_background = green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + || (alpha < 224 && green_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); + if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge { + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + } + + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 + && green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + { + background_mask[pixel_index] = 1; + } + } + + let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14); + for _ in 0..soft_green_cleanup_rounds { + let mut expanded_mask = background_mask.clone(); + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + let green_score = green_scores[pixel_index]; + let white_score = white_scores[pixel_index]; + if !is_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score) + { + continue; + } + if !touches_generated_asset_sheet_background_mask( + x, + y, + width, + height, + &background_mask, + ) { + continue; + } + + expanded_mask[pixel_index] = 1; + changed_this_round = true; + } + } + background_mask = expanded_mask; + if !changed_this_round { + break; + } + } + + for _ in 0..2 { + let mut expanded_mask = background_mask.clone(); + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let alpha = pixels[pixel_index * 4 + 3]; + let green_score = green_scores[pixel_index]; + let white_score = white_scores[pixel_index]; + let hint = background_hints[pixel_index]; + let soft_matte_candidate = alpha < 224 + || white_score > 0.10 + || green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE; + if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate { + continue; + } + + let mut adjacent_background_count = 0usize; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + adjacent_background_count += 1; + continue; + } + if background_mask[next_y as usize * width + next_x as usize] != 0 { + adjacent_background_count += 1; + } + } + } + + if adjacent_background_count >= 2 + || (adjacent_background_count >= 1 + && hint >= GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) + { + expanded_mask[pixel_index] = 1; + } + } + } + background_mask = expanded_mask; + } + + let mut changed = false; + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 { + continue; + } + let alpha_offset = pixel_index * 4 + 3; + if pixels[alpha_offset] != 0 { + pixels[alpha_offset] = 0; + changed = true; + } + } + + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + let offset = pixel_index * 4; + let alpha = pixels[offset + 3]; + if alpha == 0 { + continue; + } + + let mut touches_transparent_edge = false; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 + { + touches_transparent_edge = true; + continue; + } + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 + || pixels[next_pixel_index * 4 + 3] < 16 + { + touches_transparent_edge = true; + } + } + } + + if !touches_transparent_edge { + continue; + } + + let green_score = green_scores[pixel_index]; + let white_score = white_scores[pixel_index]; + let contamination = green_score.max(white_score).max(if alpha < 220 { + ((220 - alpha) as f32 / 220.0) * 0.25 + } else { + 0.0 + }); + if contamination < 0.06 { + continue; + } + + let sample = collect_generated_asset_sheet_foreground_neighbor_color( + pixels, + width, + height, + x, + y, + &background_mask, + &background_hints, + ); + let mut red = pixels[offset] as f32; + let mut green = pixels[offset + 1] as f32; + let mut blue = pixels[offset + 2] as f32; + let blend = clamp_generated_asset_sheet_unit(contamination.max(0.22)); + + if let Some((sample_red, sample_green, sample_blue)) = sample { + red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend); + green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend); + blue = lerp_generated_asset_sheet_channel(blue, sample_blue as f32, blend); + + if green_score > 0.04 { + green = green.min(sample_green as f32 + 18.0); + } + if white_score > 0.1 { + red = red.min(sample_red as f32 + 26.0); + green = green.min(sample_green as f32 + 26.0); + blue = blue.min(sample_blue as f32 + 26.0); + } + } else { + if green_score > 0.04 { + let toned_green = (green - (green - red.max(blue)) * 0.78) + .round() + .max(red.max(blue)); + green = green.min(toned_green).min(red.max(blue) + 18.0); + } + + if white_score > 0.12 { + let spread = red.max(green).max(blue) - red.min(green).min(blue); + if spread < 20.0 { + let toned_value = ((red + green + blue) / 3.0 * 0.88).round(); + red = red.min(toned_value); + green = green.min(toned_value); + blue = blue.min(toned_value); + } + } + } + + let mut next_alpha = alpha; + let edge_fade = (green_score * 0.35).max(white_score * 0.28); + if edge_fade > 0.08 { + next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; + if next_alpha < 10 { + next_alpha = 0; + } + } + + let next_red = red.round().clamp(0.0, 255.0) as u8; + let next_green = green.round().clamp(0.0, 255.0) as u8; + let next_blue = blue.round().clamp(0.0, 255.0) as u8; + if next_red != pixels[offset] + || next_green != pixels[offset + 1] + || next_blue != pixels[offset + 2] + || next_alpha != alpha + { + pixels[offset] = next_red; + pixels[offset + 1] = next_green; + pixels[offset + 2] = next_blue; + pixels[offset + 3] = next_alpha; + changed = true; + } + } + } + + changed +} + +fn collect_generated_asset_sheet_foreground_neighbor_color( + pixels: &[u8], + width: usize, + height: usize, + x: usize, + y: usize, + background_mask: &[u8], + background_hints: &[f32], +) -> Option<(u8, u8, u8)> { + let mut total_weight = 0.0f32; + let mut total_red = 0.0f32; + let mut total_green = 0.0f32; + let mut total_blue = 0.0f32; + + for offset_y in -2i32..=2 { + for offset_x in -2i32..=2 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + continue; + } + + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 || background_hints[next_pixel_index] >= 0.18 + { + continue; + } + + let next_offset = next_pixel_index * 4; + let next_alpha = pixels[next_offset + 3]; + if next_alpha < 96 { + continue; + } + let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); + let weight = (next_alpha as f32 / 255.0) + * if distance <= 1 { + 1.8 + } else if distance == 2 { + 1.2 + } else { + 0.7 + }; + + total_weight += weight; + total_red += pixels[next_offset] as f32 * weight; + total_green += pixels[next_offset + 1] as f32 * weight; + total_blue += pixels[next_offset + 2] as f32 * weight; + } + } + + if total_weight <= 0.0 { + return None; + } + + Some(( + (total_red / total_weight).round() as u8, + (total_green / total_weight).round() as u8, + (total_blue / total_weight).round() as u8, + )) +} diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs new file mode 100644 index 00000000..833082ed --- /dev/null +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs @@ -0,0 +1,162 @@ +pub(super) const GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 36; +pub(super) const GENERATED_ASSET_SHEET_FOREGROUND_ALPHA_THRESHOLD: i32 = 36; +pub(super) const GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE: f32 = 0.34; +pub(super) const GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18; +pub(super) const GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE: f32 = 0.82; + +pub(super) fn clamp_generated_asset_sheet_unit(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} + +pub(super) fn lerp_generated_asset_sheet_channel(from: f32, to: f32, t: f32) -> f32 { + from + (to - from) * clamp_generated_asset_sheet_unit(t) +} + +pub(super) fn is_generated_asset_sheet_foreground_pixel( + pixel: [u8; 4], + background: [u8; 4], +) -> bool { + let alpha_diff = pixel[3] as i32 - background[3] as i32; + if alpha_diff.abs() >= GENERATED_ASSET_SHEET_FOREGROUND_ALPHA_THRESHOLD && pixel[3] > 24 { + return true; + } + if pixel[3] <= 24 { + return false; + } + + let color_diff = (pixel[0] as i32 - background[0] as i32).abs() + + (pixel[1] as i32 - background[1] as i32).abs() + + (pixel[2] as i32 - background[2] as i32).abs(); + color_diff >= GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD +} + +pub(super) fn is_generated_asset_sheet_view_background_pixel(pixel: [u8; 4]) -> bool { + pixel[3] < 16 + || is_generated_asset_sheet_soft_edge_pixel(pixel) + || compute_generated_asset_sheet_white_screen_score(pixel) > 0.18 +} + +pub(super) fn is_generated_asset_sheet_visible_pixel(pixel: [u8; 4]) -> bool { + pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8) +} + +pub(super) fn is_generated_asset_sheet_soft_edge_pixel(pixel: [u8; 4]) -> bool { + if pixel[3] == 0 { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 188 + && green.saturating_sub(red.max(blue)) >= 42 + && (red >= 48 || blue >= 96 || pixel[3] < 236) +} + +pub(super) fn is_generated_asset_sheet_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool { + if pixel[3] == 0 { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 72 && green.saturating_sub(red.max(blue)) >= 18 +} + +pub(super) fn is_generated_asset_sheet_strong_green_contamination(pixel: [u8; 4]) -> bool { + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 148 && green.saturating_sub(red.max(blue)) >= 34 +} + +pub(super) fn touches_generated_asset_sheet_background_mask( + x: usize, + y: usize, + width: usize, + height: usize, + background_mask: &[u8], +) -> bool { + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + return true; + } + if background_mask[next_y as usize * width + next_x as usize] != 0 { + return true; + } + } + } + false +} + +pub(super) fn is_generated_asset_sheet_soft_green_matte_pixel( + pixel: [u8; 4], + green_score: f32, + white_score: f32, +) -> bool { + if pixel[3] == 0 || green_score < GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + let foreground_mix = red.max(blue); + green >= 188 + && white_score < 0.34 + && green.saturating_sub(foreground_mix) >= 42 + && (red >= 48 || blue >= 96 || pixel[3] < 236) +} + +pub(super) fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) -> f32 { + if pixel[3] == 0 { + return 1.0; + } + + let red = pixel[0] as f32; + let green = pixel[1] as f32; + let blue = pixel[2] as f32; + let green_lead = green - red.max(blue); + if green < 96.0 || green_lead <= 18.0 { + return 0.0; + } + + let green_ratio = green / (red + blue).max(1.0); + if green_ratio <= 0.9 { + return 0.0; + } + + (((green - 96.0) / 128.0).clamp(0.0, 1.0) * 0.34 + + ((green_lead - 18.0) / 120.0).clamp(0.0, 1.0) * 0.46 + + ((green_ratio - 0.9) / 2.4).clamp(0.0, 1.0) * 0.20) + .clamp(0.0, 1.0) +} + +pub(super) fn compute_generated_asset_sheet_white_screen_score(pixel: [u8; 4]) -> f32 { + if pixel[3] == 0 { + return 1.0; + } + + let red = pixel[0] as f32; + let green = pixel[1] as f32; + let blue = pixel[2] as f32; + let max_channel = red.max(green).max(blue); + let min_channel = red.min(green).min(blue); + let average = (red + green + blue) / 3.0; + if average < 188.0 || min_channel < 168.0 { + return 0.0; + } + + let spread = max_channel - min_channel; + let neutrality = 1.0 - clamp_generated_asset_sheet_unit((spread - 6.0) / 34.0); + let brightness = clamp_generated_asset_sheet_unit((average - 188.0) / 55.0); + let floor = clamp_generated_asset_sheet_unit((min_channel - 168.0) / 60.0); + clamp_generated_asset_sheet_unit(neutrality * (brightness * 0.85 + floor * 0.15)) +} diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/error.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/error.rs new file mode 100644 index 00000000..f0190904 --- /dev/null +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/error.rs @@ -0,0 +1,62 @@ +use std::{error::Error, fmt}; + +use platform_oss::OssError; + +pub const GENERATED_ASSET_SHEET_PROVIDER: &str = "generated-asset-sheets"; + +#[derive(Debug)] +pub enum GeneratedAssetSheetError { + InvalidRequest { message: String }, + DecodeImage { message: String }, + EncodeImage { message: String }, + BuildHttpClient { message: String }, + Oss(OssError), +} + +impl GeneratedAssetSheetError { + pub fn provider(&self) -> &'static str { + GENERATED_ASSET_SHEET_PROVIDER + } + + pub fn message(&self) -> String { + match self { + Self::InvalidRequest { message } + | Self::DecodeImage { message } + | Self::EncodeImage { message } + | Self::BuildHttpClient { message } => message.clone(), + Self::Oss(error) => error.to_string(), + } + } + + pub fn invalid_request(message: impl Into) -> Self { + Self::InvalidRequest { + message: message.into(), + } + } + + pub fn decode_image(message: impl Into) -> Self { + Self::DecodeImage { + message: message.into(), + } + } + + pub fn encode_image(message: impl Into) -> Self { + Self::EncodeImage { + message: message.into(), + } + } + + pub fn build_http_client(message: impl Into) -> Self { + Self::BuildHttpClient { + message: message.into(), + } + } +} + +impl fmt::Display for GeneratedAssetSheetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message().as_str()) + } +} + +impl Error for GeneratedAssetSheetError {} diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs new file mode 100644 index 00000000..1abfdff2 --- /dev/null +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs @@ -0,0 +1,18 @@ +pub mod alpha; +mod color; +pub mod error; +pub mod persist; +pub mod prompt; +pub mod sheet; + +pub use alpha::apply_generated_asset_sheet_green_screen_alpha; +pub use error::GeneratedAssetSheetError; +pub use persist::{ + GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload, + persist_generated_asset_sheet_bytes, prepare_generated_asset_sheet_put_request, +}; +pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt}; +pub use sheet::{ + GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte, + slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row, +}; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/persist.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/persist.rs new file mode 100644 index 00000000..596d1bc6 --- /dev/null +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/persist.rs @@ -0,0 +1,203 @@ +use std::{collections::BTreeMap, time::Duration}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use platform_oss::{LegacyAssetPrefix, OssClient, OssObjectAccess, OssPutObjectRequest}; + +use super::error::GeneratedAssetSheetError; + +const GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GeneratedAssetSheetUpload { + pub src: String, + pub object_key: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GeneratedAssetSheetPersistPrompt { + pub sheet_prompt: Option, + pub item_name_prompt: Option, + pub special_prompt: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GeneratedAssetSheetPersistInput { + pub prefix: LegacyAssetPrefix, + pub owner_user_id: String, + pub session_id: String, + pub profile_id: String, + pub path_segments: Vec, + pub file_name: String, + pub content_type: String, + pub bytes: Vec, + pub asset_kind: String, + pub source_job_id: Option, + pub generated_at_micros: i64, + pub grid_size: usize, + pub row_index: usize, + pub view_index: usize, + pub prompt: GeneratedAssetSheetPersistPrompt, +} + +pub fn prepare_generated_asset_sheet_put_request( + input: GeneratedAssetSheetPersistInput, +) -> Result { + if input.grid_size == 0 { + return Err(GeneratedAssetSheetError::invalid_request( + "系列素材图集的 n 必须大于 0。", + )); + } + if input.row_index == 0 + || input.view_index == 0 + || input.row_index > input.grid_size + || input.view_index > input.grid_size + { + return Err(GeneratedAssetSheetError::invalid_request(format!( + "系列素材图集持久化的行列索引必须落在 n*n 范围内。gridSize={}, rowIndex={}, viewIndex={}", + input.grid_size, input.row_index, input.view_index + ))); + } + + let mut metadata = BTreeMap::new(); + metadata.insert( + "x-oss-meta-asset-kind".to_string(), + input.asset_kind.clone(), + ); + metadata.insert( + "x-oss-meta-owner-user-id".to_string(), + input.owner_user_id.clone(), + ); + metadata.insert( + "x-oss-meta-profile-id".to_string(), + input.profile_id.clone(), + ); + metadata.insert( + "x-oss-meta-generated-asset-sheet-grid-size".to_string(), + input.grid_size.to_string(), + ); + metadata.insert( + "x-oss-meta-generated-asset-sheet-row-index".to_string(), + input.row_index.to_string(), + ); + metadata.insert( + "x-oss-meta-generated-asset-sheet-view-index".to_string(), + input.view_index.to_string(), + ); + metadata.insert( + "x-oss-meta-generated-at-micros".to_string(), + input.generated_at_micros.to_string(), + ); + if let Some(source_job_id) = input + .source_job_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + metadata.insert( + "x-oss-meta-source-job-id".to_string(), + source_job_id.to_string(), + ); + } + insert_generated_asset_sheet_prompt_metadata( + &mut metadata, + "generated-asset-sheet-prompt-b64", + input.prompt.sheet_prompt.as_deref(), + ); + insert_generated_asset_sheet_prompt_metadata( + &mut metadata, + "generated-asset-sheet-item-name-prompt-b64", + input.prompt.item_name_prompt.as_deref(), + ); + insert_generated_asset_sheet_prompt_metadata( + &mut metadata, + "generated-asset-sheet-special-prompt-b64", + input.prompt.special_prompt.as_deref(), + ); + if input.prompt.sheet_prompt.is_some() + || input.prompt.item_name_prompt.is_some() + || input.prompt.special_prompt.is_some() + { + metadata.insert( + "x-oss-meta-generated-asset-sheet-prompt-encoding".to_string(), + "utf8-base64".to_string(), + ); + } + + Ok(OssPutObjectRequest { + prefix: input.prefix, + path_segments: std::iter::once(input.session_id.as_str()) + .chain(std::iter::once(input.profile_id.as_str())) + .chain(input.path_segments.iter().map(String::as_str)) + .map(|segment| sanitize_generated_asset_sheet_path_segment(segment, "asset")) + .collect(), + file_name: input.file_name, + content_type: Some(input.content_type), + access: OssObjectAccess::Private, + metadata, + body: input.bytes, + }) +} + +pub async fn persist_generated_asset_sheet_bytes( + oss_client: &OssClient, + input: GeneratedAssetSheetPersistInput, +) -> Result { + let put_request = prepare_generated_asset_sheet_put_request(input)?; + let oss_http_client = reqwest::Client::builder() + .timeout(Duration::from_millis( + GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS, + )) + .build() + .map_err(|error| { + GeneratedAssetSheetError::build_http_client(format!( + "构造系列素材图集 OSS 上传客户端失败:{error}" + )) + })?; + let put_result = oss_client + .put_object(&oss_http_client, put_request) + .await + .map_err(GeneratedAssetSheetError::Oss)?; + + Ok(GeneratedAssetSheetUpload { + src: put_result.legacy_public_path, + object_key: put_result.object_key, + }) +} + +fn insert_generated_asset_sheet_prompt_metadata( + metadata: &mut BTreeMap, + key: &str, + value: Option<&str>, +) { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return; + }; + metadata.insert( + format!("x-oss-meta-{key}"), + BASE64_STANDARD.encode(value.as_bytes()), + ); +} + +fn sanitize_generated_asset_sheet_path_segment(raw: &str, fallback: &str) -> String { + let normalized = raw + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + let collapsed = normalized + .split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-"); + if collapsed.is_empty() { + fallback.to_string() + } else { + collapsed.chars().take(64).collect() + } +} diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/prompt.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/prompt.rs new file mode 100644 index 00000000..8cf6d9b7 --- /dev/null +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/prompt.rs @@ -0,0 +1,65 @@ +use super::error::GeneratedAssetSheetError; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GeneratedAssetSheetPromptInput<'a> { + pub subject_text: &'a str, + pub item_names: &'a [String], + pub grid_size: usize, + pub item_name_prompt_template: Option<&'a str>, + pub special_prompt: Option<&'a str>, +} + +pub fn build_generated_asset_sheet_prompt( + input: &GeneratedAssetSheetPromptInput<'_>, +) -> Result { + let grid_size = input.grid_size; + if grid_size == 0 { + return Err(GeneratedAssetSheetError::invalid_request( + "系列素材图集的 n 必须大于 0。", + )); + } + if input.item_names.len() > grid_size { + return Err(GeneratedAssetSheetError::invalid_request(format!( + "系列素材图集的物品行数不能超过 n。gridSize={grid_size}, itemCount={}", + input.item_names.len() + ))); + } + + let subject_text = input.subject_text.trim(); + let subject_text = if subject_text.is_empty() { + "系列素材" + } else { + subject_text + }; + let item_rows = input + .item_names + .iter() + .enumerate() + .map(|(index, item_name)| { + let row_index = index + 1; + let item_name = item_name.trim(); + if let Some(template) = input + .item_name_prompt_template + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return template + .replace("{row_index}", row_index.to_string().as_str()) + .replace("{item_name}", item_name) + .replace("{view_count}", grid_size.to_string().as_str()); + } + format!("第{row_index}行:{item_name} 的 {grid_size} 个不同视图") + }) + .collect::>() + .join(";"); + let special_prompt = input + .special_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("每个物品生成 {grid_size} 个不同视图。")); + + Ok(format!( + "生成一张1:1图片。固定生成{grid_size}行*{grid_size}列网格素材图,画面是{subject_text}。严格{grid_size}*{grid_size}均匀排布,严格按行组织:{item_rows}。{special_prompt}每个格子一个独立居中的完整素材,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若素材天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D素材。请让每个素材完整落在自己的格子中央,四周保留留白,相邻素材主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,素材主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。" + )) +} diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs new file mode 100644 index 00000000..8d2a6d6a --- /dev/null +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -0,0 +1,672 @@ +use super::alpha::apply_generated_asset_sheet_green_screen_alpha; +use super::color::{ + is_generated_asset_sheet_foreground_pixel, + is_generated_asset_sheet_green_contaminated_edge_pixel, + is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination, + is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel, + touches_generated_asset_sheet_background_mask, +}; +use super::error::GeneratedAssetSheetError; +use image::{GenericImageView, ImageFormat}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GeneratedAssetSheetSliceImage { + pub bytes: Vec, +} + +pub fn slice_generated_asset_sheet( + image: &crate::DownloadedImage, + item_names: &[String], + grid_size: usize, +) -> Result>, GeneratedAssetSheetError> { + if grid_size == 0 { + return Err(GeneratedAssetSheetError::invalid_request( + "系列素材图集的 n 必须大于 0。", + )); + } + + let grid_size_u32 = u32::try_from(grid_size).map_err(|_| { + GeneratedAssetSheetError::invalid_request("系列素材图集的 n 超出可支持范围。") + })?; + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + GeneratedAssetSheetError::decode_image(format!("系列素材图集解码失败:{error}")) + })?; + let source = apply_generated_asset_sheet_green_screen_alpha(source); + let (width, height) = source.dimensions(); + let cell_width = width / grid_size_u32; + let cell_height = height / grid_size_u32; + if cell_width == 0 || cell_height == 0 { + return Err(GeneratedAssetSheetError::invalid_request( + "系列素材图集尺寸过小,无法切割。", + )); + } + + let mut slices = Vec::with_capacity(item_names.len().min(grid_size)); + for item_index in 0..item_names.len().min(grid_size) { + let row = item_index as u32; + let mut views = Vec::with_capacity(grid_size); + for view_index in 0..grid_size { + let col = view_index as u32; + let (crop_x, crop_y, crop_width, crop_height) = + resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col); + let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); + let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|error| { + GeneratedAssetSheetError::encode_image(format!("系列素材图集切割失败:{error}")) + })?; + views.push(GeneratedAssetSheetSliceImage { + bytes: cursor.into_inner(), + }); + } + slices.push(views); + } + + Ok(slices) +} + +pub fn slice_generated_asset_sheet_two_items_per_row( + image: &crate::DownloadedImage, + item_names: &[String], + grid_size: usize, + views_per_item: usize, +) -> Result>, GeneratedAssetSheetError> { + if grid_size == 0 || views_per_item == 0 { + return Err(GeneratedAssetSheetError::invalid_request( + "系列素材图集的 n 和每物品视图数必须大于 0。", + )); + } + if !grid_size.is_multiple_of(views_per_item) { + return Err(GeneratedAssetSheetError::invalid_request(format!( + "系列素材图集每行必须能均分为若干物品。gridSize={}, viewsPerItem={}", + grid_size, views_per_item + ))); + } + + let grid_size_u32 = u32::try_from(grid_size).map_err(|_| { + GeneratedAssetSheetError::invalid_request("系列素材图集的 n 超出可支持范围。") + })?; + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + GeneratedAssetSheetError::decode_image(format!("系列素材图集解码失败:{error}")) + })?; + let source = apply_generated_asset_sheet_green_screen_alpha(source); + let (width, height) = source.dimensions(); + if width / grid_size_u32 == 0 || height / grid_size_u32 == 0 { + return Err(GeneratedAssetSheetError::invalid_request( + "系列素材图集尺寸过小,无法切割。", + )); + } + + let items_per_row = grid_size / views_per_item; + let max_item_count = grid_size.saturating_mul(items_per_row); + let mut slices = Vec::with_capacity(item_names.len().min(max_item_count)); + for item_index in 0..item_names.len().min(max_item_count) { + let row = (item_index / items_per_row) as u32; + let start_col = ((item_index % items_per_row) * views_per_item) as u32; + let mut views = Vec::with_capacity(views_per_item); + for view_offset in 0..views_per_item { + let col = start_col + view_offset as u32; + let (crop_x, crop_y, crop_width, crop_height) = + resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col); + let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); + let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|error| { + GeneratedAssetSheetError::encode_image(format!("系列素材图集切割失败:{error}")) + })?; + views.push(GeneratedAssetSheetSliceImage { + bytes: cursor.into_inner(), + }); + } + slices.push(views); + } + + Ok(slices) +} + +pub fn crop_generated_asset_sheet_view_edge_matte( + image: image::DynamicImage, +) -> image::DynamicImage { + let mut image = image.to_rgba8(); + let (width, height) = image.dimensions(); + remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize); + let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| { + GeneratedAssetSheetCellBounds { + x0: 0, + y0: 0, + x1: width, + y1: height, + } + }); + if bounds.x0 == 0 && bounds.y0 == 0 && bounds.x1 == width && bounds.y1 == height { + return image::DynamicImage::ImageRgba8(image); + } + + image::DynamicImage::ImageRgba8( + image::imageops::crop_imm( + &image, + bounds.x0, + bounds.y0, + bounds.width(), + bounds.height(), + ) + .to_image(), + ) +} + +#[derive(Clone, Copy, Debug)] +struct GeneratedAssetSheetCellBounds { + x0: u32, + y0: u32, + x1: u32, + y1: u32, +} + +impl GeneratedAssetSheetCellBounds { + fn width(self) -> u32 { + self.x1.saturating_sub(self.x0).max(1) + } + + fn height(self) -> u32 { + self.y1.saturating_sub(self.y0).max(1) + } + + fn area(self) -> u32 { + self.width().saturating_mul(self.height()) + } + + fn to_crop_tuple(self) -> (u32, u32, u32, u32) { + (self.x0, self.y0, self.width(), self.height()) + } +} + +fn resolve_generated_asset_sheet_cell_crop( + source: &image::DynamicImage, + grid_size: u32, + row: u32, + col: u32, +) -> (u32, u32, u32, u32) { + let (image_width, image_height) = source.dimensions(); + let cell = + resolve_generated_asset_sheet_cell_bounds(image_width, image_height, grid_size, row, col); + let Some(foreground) = detect_generated_asset_sheet_foreground_bounds(source, cell) else { + return cell.to_crop_tuple(); + }; + + let cell_width = cell.width(); + let cell_height = cell.height(); + let pad_x = (cell_width / 16).clamp(4, 16); + let pad_y = (cell_height / 16).clamp(4, 16); + let crop = GeneratedAssetSheetCellBounds { + x0: foreground.x0.saturating_sub(pad_x).max(cell.x0), + y0: foreground.y0.saturating_sub(pad_y).max(cell.y0), + x1: foreground.x1.saturating_add(pad_x).min(cell.x1), + y1: foreground.y1.saturating_add(pad_y).min(cell.y1), + }; + + crop.to_crop_tuple() +} + +fn resolve_generated_asset_sheet_cell_bounds( + image_width: u32, + image_height: u32, + grid_size: u32, + row: u32, + col: u32, +) -> GeneratedAssetSheetCellBounds { + let normalized_grid_size = grid_size.max(1); + let cell_x0 = col.saturating_mul(image_width) / normalized_grid_size; + let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / normalized_grid_size; + let cell_y0 = row.saturating_mul(image_height) / normalized_grid_size; + let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_grid_size; + + GeneratedAssetSheetCellBounds { + x0: cell_x0.min(image_width.saturating_sub(1)), + y0: cell_y0.min(image_height.saturating_sub(1)), + x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width), + y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height), + } +} + +fn detect_generated_asset_sheet_foreground_bounds( + source: &image::DynamicImage, + cell: GeneratedAssetSheetCellBounds, +) -> Option { + let background = sample_generated_asset_sheet_cell_background(source, cell); + let mut foreground: Option = None; + let mut foreground_pixels = 0u32; + + for y in cell.y0..cell.y1 { + for x in cell.x0..cell.x1 { + if !is_generated_asset_sheet_foreground_pixel(source.get_pixel(x, y).0, background) { + continue; + } + foreground_pixels = foreground_pixels.saturating_add(1); + foreground = Some(match foreground { + Some(bounds) => GeneratedAssetSheetCellBounds { + x0: bounds.x0.min(x), + y0: bounds.y0.min(y), + x1: bounds.x1.max(x.saturating_add(1)), + y1: bounds.y1.max(y.saturating_add(1)), + }, + None => GeneratedAssetSheetCellBounds { + x0: x, + y0: y, + x1: x.saturating_add(1), + y1: y.saturating_add(1), + }, + }); + } + } + + let min_foreground_pixels = (cell.area() / 320).clamp(12, 220); + foreground.filter(|bounds| { + foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2 + }) +} + +fn detect_generated_asset_sheet_visible_bounds( + image: &image::RgbaImage, +) -> Option { + let (width, height) = image.dimensions(); + let mut bounds: Option = None; + let mut visible_pixels = 0u32; + + for y in 0..height { + for x in 0..width { + let pixel = image.get_pixel(x, y).0; + if !is_generated_asset_sheet_visible_pixel(pixel) { + continue; + } + visible_pixels = visible_pixels.saturating_add(1); + bounds = Some(match bounds { + Some(current) => GeneratedAssetSheetCellBounds { + x0: current.x0.min(x), + y0: current.y0.min(y), + x1: current.x1.max(x.saturating_add(1)), + y1: current.y1.max(y.saturating_add(1)), + }, + None => GeneratedAssetSheetCellBounds { + x0: x, + y0: y, + x1: x.saturating_add(1), + y1: y.saturating_add(1), + }, + }); + } + } + + let min_visible_pixels = ((width.saturating_mul(height)) / 540).clamp(10, 120); + bounds.filter(|visible_bounds| { + visible_pixels >= min_visible_pixels + && visible_bounds.width() > 2 + && visible_bounds.height() > 2 + }) +} + +fn sample_generated_asset_sheet_cell_background( + source: &image::DynamicImage, + cell: GeneratedAssetSheetCellBounds, +) -> [u8; 4] { + let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8); + let sample_points = [ + (cell.x0, cell.y0), + (cell.x1.saturating_sub(sample_size), cell.y0), + (cell.x0, cell.y1.saturating_sub(sample_size)), + ( + cell.x1.saturating_sub(sample_size), + cell.y1.saturating_sub(sample_size), + ), + ]; + let mut samples = Vec::new(); + for (start_x, start_y) in sample_points { + let mut totals = [0u32; 4]; + let mut count = 0u32; + for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) { + for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) { + let pixel = source.get_pixel(x, y).0; + totals[0] = totals[0].saturating_add(pixel[0] as u32); + totals[1] = totals[1].saturating_add(pixel[1] as u32); + totals[2] = totals[2].saturating_add(pixel[2] as u32); + totals[3] = totals[3].saturating_add(pixel[3] as u32); + count = count.saturating_add(1); + } + } + if count > 0 { + samples.push([ + (totals[0] / count) as u8, + (totals[1] / count) as u8, + (totals[2] / count) as u8, + (totals[3] / count) as u8, + ]); + } + } + + samples + .into_iter() + .min_by_key(|sample| { + let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16; + (sample[3] as u16, u16::MAX.saturating_sub(luminance)) + }) + .unwrap_or([255, 255, 255, 255]) +} + +fn remove_generated_asset_sheet_view_edge_matte( + pixels: &mut [u8], + width: usize, + height: usize, +) -> bool { + let pixel_count = width.saturating_mul(height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let mut changed = false; + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + let mut transparent_pixel_count = 0usize; + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + if pixels[offset + 3] == 0 { + background_mask[pixel_index] = 1; + queue.push(pixel_index); + transparent_pixel_count = transparent_pixel_count.saturating_add(1); + } + } + let has_transparent_background = transparent_pixel_count > pixel_count / 200; + + // 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘; + // 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte,避免误伤贴边主体。 + let edge_width = resolve_generated_asset_sheet_view_edge_cleanup_width(width, height); + for y in 0..height { + for x in 0..width { + if x >= edge_width + && y >= edge_width + && x.saturating_add(edge_width) < width + && y.saturating_add(edge_width) < height + { + continue; + } + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_generated_asset_sheet_view_background_pixel(pixel) { + continue; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + } + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + let x = pixel_index % width; + let y = pixel_index / width; + let neighbors = [ + (x > 0).then(|| pixel_index - 1), + (x + 1 < width).then_some(pixel_index + 1), + (y > 0).then(|| pixel_index - width), + (y + 1 < height).then_some(pixel_index + width), + ]; + + for next_pixel_index in neighbors.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let offset = next_pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_generated_asset_sheet_view_background_pixel(pixel) { + continue; + } + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + + for _ in 0..edge_width { + let mut expanded_mask = background_mask.clone(); + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + if !is_generated_asset_sheet_view_background_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + continue; + } + + if touches_generated_asset_sheet_background_mask( + x, + y, + width, + height, + &background_mask, + ) { + expanded_mask[pixel_index] = 1; + changed_this_round = true; + } + } + } + background_mask = expanded_mask; + if !changed_this_round { + break; + } + } + + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 { + continue; + } + let offset = pixel_index * 4; + if pixels[offset + 3] != 0 + || pixels[offset] != 0 + || pixels[offset + 1] != 0 + || pixels[offset + 2] != 0 + { + pixels[offset] = 0; + pixels[offset + 1] = 0; + pixels[offset + 2] = 0; + pixels[offset + 3] = 0; + changed = true; + } + } + + if has_transparent_background { + let mut visible_mask = vec![0u8; pixel_count]; + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + if is_generated_asset_sheet_visible_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + visible_mask[pixel_index] = 1; + } + } + + for _ in 0..2 { + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if visible_mask[pixel_index] == 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) { + continue; + } + if !touches_generated_asset_sheet_background_mask( + x, + y, + width, + height, + &background_mask, + ) { + continue; + } + + if is_generated_asset_sheet_strong_green_contamination(pixel) { + pixels[offset] = 0; + pixels[offset + 1] = 0; + pixels[offset + 2] = 0; + pixels[offset + 3] = 0; + visible_mask[pixel_index] = 0; + background_mask[pixel_index] = 1; + changed = true; + changed_this_round = true; + continue; + } + + let replacement = collect_generated_asset_sheet_visible_neighbor_color( + pixels, + width, + height, + x, + y, + &background_mask, + &visible_mask, + ) + .unwrap_or(( + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + )); + let next_red = replacement.0.max(pixels[offset]); + let next_blue = replacement.2.max(pixels[offset + 2]); + let next_green = replacement + .1 + .min(next_red.max(next_blue).saturating_add(12)); + if next_red != pixels[offset] + || next_green != pixels[offset + 1] + || next_blue != pixels[offset + 2] + { + pixels[offset] = next_red; + pixels[offset + 1] = next_green; + pixels[offset + 2] = next_blue; + changed = true; + changed_this_round = true; + } + background_mask[pixel_index] = 1; + } + } + if !changed_this_round { + break; + } + } + } + + changed +} + +fn resolve_generated_asset_sheet_view_edge_cleanup_width(width: usize, height: usize) -> usize { + let min_side = width.min(height).max(1); + (min_side / 24).clamp(4, 12).min(min_side) +} + +fn collect_generated_asset_sheet_visible_neighbor_color( + pixels: &[u8], + width: usize, + height: usize, + x: usize, + y: usize, + background_mask: &[u8], + visible_mask: &[u8], +) -> Option<(u8, u8, u8)> { + let mut total_weight = 0.0f32; + let mut total_red = 0.0f32; + let mut total_green = 0.0f32; + let mut total_blue = 0.0f32; + + for offset_y in -3i32..=3 { + for offset_x in -3i32..=3 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + continue; + } + + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 { + continue; + } + + let next_offset = next_pixel_index * 4; + let next_alpha = pixels[next_offset + 3]; + if next_alpha < 96 { + continue; + } + let pixel = [ + pixels[next_offset], + pixels[next_offset + 1], + pixels[next_offset + 2], + next_alpha, + ]; + if is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) + || is_generated_asset_sheet_soft_edge_pixel(pixel) + { + continue; + } + + let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); + let weight = (next_alpha as f32 / 255.0) + * if distance <= 1 { + 2.0 + } else if distance <= 3 { + 1.2 + } else { + 0.7 + }; + total_weight += weight; + total_red += pixels[next_offset] as f32 * weight; + total_green += pixels[next_offset + 1] as f32 * weight; + total_blue += pixels[next_offset + 2] as f32 * weight; + } + } + + if total_weight <= 0.0 { + return None; + } + + Some(( + (total_red / total_weight).round() as u8, + (total_green / total_weight).round() as u8, + (total_blue / total_weight).round() as u8, + )) +} diff --git a/server-rs/crates/platform-image/src/generated_assets.rs b/server-rs/crates/platform-image/src/generated_assets.rs new file mode 100644 index 00000000..d3317254 --- /dev/null +++ b/server-rs/crates/platform-image/src/generated_assets.rs @@ -0,0 +1,2 @@ +pub mod adapter; +pub mod helpers; diff --git a/server-rs/crates/api-server/src/generated_image_assets/adapter.rs b/server-rs/crates/platform-image/src/generated_assets/adapter.rs similarity index 75% rename from server-rs/crates/api-server/src/generated_image_assets/adapter.rs rename to server-rs/crates/platform-image/src/generated_assets/adapter.rs index 76432896..666c2296 100644 --- a/server-rs/crates/api-server/src/generated_image_assets/adapter.rs +++ b/server-rs/crates/platform-image/src/generated_assets/adapter.rs @@ -9,49 +9,46 @@ use super::helpers::{ }; #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedImageAssetAdapterBoundary; +pub struct GeneratedImageAssetAdapterBoundary; impl GeneratedImageAssetAdapterBoundary { - pub(crate) const BILLING_BOUNDARY_COMMENT: &'static str = "generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence."; + pub const BILLING_BOUNDARY_COMMENT: &'static str = "generated_image_assets adapter only prepares asset payloads; callers own billing before or after persistence."; } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedImageAssetAdapter; +pub struct GeneratedImageAssetAdapter; #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedImageAssetPersistInput { - pub(crate) prefix: LegacyAssetPrefix, - pub(crate) path_segments: Vec, - pub(crate) file_stem: String, - pub(crate) image: GeneratedImageAssetDataUrl, - pub(crate) access: OssObjectAccess, - pub(crate) metadata: GeneratedImageAssetAdapterMetadata, - pub(crate) extra_metadata: BTreeMap, +pub struct GeneratedImageAssetPersistInput { + pub prefix: LegacyAssetPrefix, + pub path_segments: Vec, + pub file_stem: String, + pub image: GeneratedImageAssetDataUrl, + pub access: OssObjectAccess, + pub metadata: GeneratedImageAssetAdapterMetadata, + pub extra_metadata: BTreeMap, } #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct GeneratedImageAssetAdapterMetadata { - pub(crate) asset_kind: Option, - pub(crate) owner_user_id: Option, - pub(crate) entity_kind: Option, - pub(crate) entity_id: Option, - pub(crate) slot: Option, - pub(crate) provider: Option, - pub(crate) task_id: Option, +pub struct GeneratedImageAssetAdapterMetadata { + pub asset_kind: Option, + pub owner_user_id: Option, + pub entity_kind: Option, + pub entity_id: Option, + pub slot: Option, + pub provider: Option, + pub task_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedImageAssetPreparedPut { - pub(crate) request: OssPutObjectRequest, - pub(crate) storage_paths: GeneratedImageAssetStoragePaths, - pub(crate) format: GeneratedImageAssetImageFormat, +pub struct GeneratedImageAssetPreparedPut { + pub request: OssPutObjectRequest, + pub storage_paths: GeneratedImageAssetStoragePaths, + pub format: GeneratedImageAssetImageFormat, } impl GeneratedImageAssetAdapter { - /// Adapter boundary: this skeleton intentionally does not read, reserve, charge, refund, - /// or otherwise mutate billing state. Real callers must keep billing orchestration outside - /// generated_image_assets when they migrate onto this adapter. - pub(crate) fn prepare_put_object( + pub fn prepare_put_object( input: GeneratedImageAssetPersistInput, ) -> Result { let file_name = format!( @@ -105,7 +102,7 @@ mod generated_image_assets_adapter_tests { use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use super::*; - use crate::generated_image_assets::helpers::decode_generated_image_asset_data_url; + use crate::generated_assets::helpers::decode_generated_image_asset_data_url; #[test] fn generated_image_assets_adapter_prepares_put_without_billing_side_effects() { diff --git a/server-rs/crates/api-server/src/generated_image_assets/helpers.rs b/server-rs/crates/platform-image/src/generated_assets/helpers.rs similarity index 84% rename from server-rs/crates/api-server/src/generated_image_assets/helpers.rs rename to server-rs/crates/platform-image/src/generated_assets/helpers.rs index f12c98a2..ea959774 100644 --- a/server-rs/crates/api-server/src/generated_image_assets/helpers.rs +++ b/server-rs/crates/platform-image/src/generated_assets/helpers.rs @@ -6,43 +6,43 @@ use platform_oss::LegacyAssetPrefix; const DEFAULT_IMAGE_MIME: &str = "image/jpeg"; #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedImageAssetImageFormat { - pub(crate) mime_type: String, - pub(crate) extension: String, +pub struct GeneratedImageAssetImageFormat { + pub mime_type: String, + pub extension: String, } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedImageAssetDataUrl { - pub(crate) format: GeneratedImageAssetImageFormat, - pub(crate) bytes: Vec, +pub struct GeneratedImageAssetDataUrl { + pub format: GeneratedImageAssetImageFormat, + pub bytes: Vec, } #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct GeneratedImageAssetMetadataInput { - pub(crate) asset_kind: Option, - pub(crate) owner_user_id: Option, - pub(crate) entity_kind: Option, - pub(crate) entity_id: Option, - pub(crate) slot: Option, - pub(crate) provider: Option, - pub(crate) task_id: Option, +pub struct GeneratedImageAssetMetadataInput { + pub asset_kind: Option, + pub owner_user_id: Option, + pub entity_kind: Option, + pub entity_id: Option, + pub slot: Option, + pub provider: Option, + pub task_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GeneratedImageAssetStoragePaths { - pub(crate) object_key: String, - pub(crate) legacy_public_path: String, +pub struct GeneratedImageAssetStoragePaths { + pub object_key: String, + pub legacy_public_path: String, } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum GeneratedImageAssetHelperError { +pub enum GeneratedImageAssetHelperError { InvalidDataUrl, UnsupportedEncoding, DecodeBase64(String), InvalidFileName, } -pub(crate) fn normalize_generated_image_asset_mime( +pub fn normalize_generated_image_asset_mime( raw_content_type: impl AsRef, ) -> GeneratedImageAssetImageFormat { let mime_type = raw_content_type @@ -64,7 +64,7 @@ pub(crate) fn normalize_generated_image_asset_mime( } } -pub(crate) fn decode_generated_image_asset_data_url( +pub fn decode_generated_image_asset_data_url( raw_data_url: &str, ) -> Result { let (metadata, encoded) = raw_data_url @@ -97,7 +97,7 @@ pub(crate) fn decode_generated_image_asset_data_url( }) } -pub(crate) fn build_generated_image_asset_storage_paths( +pub fn build_generated_image_asset_storage_paths( prefix: LegacyAssetPrefix, path_segments: &[String], file_name: &str, @@ -119,7 +119,7 @@ pub(crate) fn build_generated_image_asset_storage_paths( }) } -pub(crate) fn build_generated_image_asset_metadata( +pub fn build_generated_image_asset_metadata( input: GeneratedImageAssetMetadataInput, ) -> BTreeMap { let mut metadata = BTreeMap::new(); @@ -133,7 +133,7 @@ pub(crate) fn build_generated_image_asset_metadata( metadata } -pub(crate) fn merge_generated_image_asset_metadata( +pub fn merge_generated_image_asset_metadata( base: BTreeMap, overlay: BTreeMap, ) -> BTreeMap { @@ -203,15 +203,24 @@ mod generated_image_assets_tests { fn generated_image_assets_normalize_mime_and_extension() { assert_eq!( normalize_generated_image_asset_mime(" image/PNG; charset=utf-8 "), - image_format("image/png", "png") + GeneratedImageAssetImageFormat { + mime_type: "image/png".to_string(), + extension: "png".to_string(), + } ); assert_eq!( normalize_generated_image_asset_mime("image/jpg"), - image_format("image/jpeg", "jpg") + GeneratedImageAssetImageFormat { + mime_type: "image/jpeg".to_string(), + extension: "jpg".to_string(), + } ); assert_eq!( normalize_generated_image_asset_mime("text/plain"), - image_format("image/jpeg", "jpg") + GeneratedImageAssetImageFormat { + mime_type: "image/jpeg".to_string(), + extension: "jpg".to_string(), + } ); } @@ -220,7 +229,13 @@ mod generated_image_assets_tests { let decoded = decode_generated_image_asset_data_url("data:image/webp;base64,aGVsbG8=") .expect("data url should decode"); - assert_eq!(decoded.format, image_format("image/webp", "webp")); + assert_eq!( + decoded.format, + GeneratedImageAssetImageFormat { + mime_type: "image/webp".to_string(), + extension: "webp".to_string(), + } + ); assert_eq!(decoded.bytes, b"hello"); } diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 0c6daf44..d341c6e3 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -1,1362 +1,13 @@ -use std::{error::Error, fmt, time::Duration}; - -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use reqwest::header; -use serde_json::{Map, Value, json}; - -pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; -pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; -pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; - -#[derive(Clone, Debug)] -pub struct VectorEngineImageSettings { - pub base_url: String, - pub api_key: String, - pub request_timeout_ms: u64, -} - -#[derive(Clone, Debug)] -pub struct GeneratedImages { - pub task_id: String, - pub actual_prompt: Option, - pub images: Vec, -} - -#[derive(Clone, Debug)] -pub struct DownloadedImage { - pub bytes: Vec, - pub mime_type: String, - pub extension: String, -} - -#[derive(Clone, Debug)] -pub struct ReferenceImage { - pub bytes: Vec, - pub mime_type: String, - pub file_name: String, -} - -#[derive(Clone, Debug)] -pub struct PlatformImageFailureAudit { - pub provider: &'static str, - pub endpoint: String, - pub operation: String, - pub failure_stage: &'static str, - pub status_code: Option, - pub status_class: Option<&'static str>, - pub timeout: bool, - pub retryable: bool, - pub error_message: String, - pub error_source: Option, - pub raw_excerpt: Option, - pub latency_ms: Option, - pub prompt_chars: Option, - pub reference_image_count: Option, - pub image_model: Option<&'static str>, -} - -#[derive(Clone, Debug)] -pub enum PlatformImageError { - InvalidConfig { - provider: &'static str, - message: String, - }, - InvalidRequest { - provider: &'static str, - message: String, - }, - Request { - provider: &'static str, - message: String, - endpoint: Option, - timeout: bool, - connect: bool, - request: bool, - body: bool, - status_code: Option, - source: Option, - audit: Option, - }, - Upstream { - provider: &'static str, - message: String, - upstream_status: u16, - raw_excerpt: String, - audit: Option, - }, - ResponseParse { - provider: &'static str, - message: String, - raw_excerpt: String, - audit: Option, - }, - MissingImage { - provider: &'static str, - message: String, - audit: Option, - }, -} - -impl PlatformImageError { - pub fn provider(&self) -> &'static str { - match self { - Self::InvalidConfig { provider, .. } - | Self::InvalidRequest { provider, .. } - | Self::Request { provider, .. } - | Self::Upstream { provider, .. } - | Self::ResponseParse { provider, .. } - | Self::MissingImage { provider, .. } => provider, - } - } - - pub fn message(&self) -> &str { - match self { - Self::InvalidConfig { message, .. } - | Self::InvalidRequest { message, .. } - | Self::Request { message, .. } - | Self::Upstream { message, .. } - | Self::ResponseParse { message, .. } - | Self::MissingImage { message, .. } => message, - } - } - - pub fn audit(&self) -> Option<&PlatformImageFailureAudit> { - match self { - Self::Request { audit, .. } - | Self::Upstream { audit, .. } - | Self::ResponseParse { audit, .. } - | Self::MissingImage { audit, .. } => audit.as_ref(), - Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => None, - } - } - - pub fn status_hint(&self) -> PlatformImageStatusHint { - match self { - Self::InvalidConfig { .. } => PlatformImageStatusHint::ServiceUnavailable, - Self::InvalidRequest { .. } => PlatformImageStatusHint::BadRequest, - Self::Request { timeout, .. } if *timeout => PlatformImageStatusHint::GatewayTimeout, - Self::Upstream { message, raw_excerpt, .. } - if is_timeout_message(message) || is_timeout_message(raw_excerpt) => - { - PlatformImageStatusHint::GatewayTimeout - } - Self::Request { .. } - | Self::Upstream { .. } - | Self::ResponseParse { .. } - | Self::MissingImage { .. } => PlatformImageStatusHint::BadGateway, - } - } -} - -impl fmt::Display for PlatformImageError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.message()) - } -} - -impl Error for PlatformImageError {} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PlatformImageStatusHint { - BadRequest, - ServiceUnavailable, - BadGateway, - GatewayTimeout, -} - -pub fn build_vector_engine_image_http_client( - settings: &VectorEngineImageSettings, -) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) - .http1_only() - .build() - .map_err(|error| PlatformImageError::InvalidConfig { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), - }) -} - -pub async fn create_vector_engine_image_generation( - http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - candidate_count: u32, - reference_images: &[String], - failure_context: &str, -) -> Result { - if !reference_images.is_empty() { - let resolved_references = - resolve_reference_images(http_client, reference_images, failure_context).await?; - return create_vector_engine_image_edit_with_references( - http_client, - settings, - prompt, - negative_prompt, - size, - candidate_count, - resolved_references.as_slice(), - failure_context, - ) - .await; - } - - let request_url = vector_engine_images_generation_url(settings); - let normalized_size = normalize_image_size(size); - let request_body = build_vector_engine_image_request_body( - prompt, - negative_prompt, - normalized_size.as_str(), - candidate_count, - reference_images, - ); - let started_at = std::time::Instant::now(); - let response = match http_client - .post(request_url.as_str()) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .header(header::CONTENT_TYPE, "application/json") - .json(&request_body) - .send() - .await - { - Ok(response) => response, - Err(error) => { - return Err(map_reqwest_error( - format!("{failure_context}:创建图片生成任务失败").as_str(), - request_url.as_str(), - "request_send", - error, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_images.len()), - )); - } - }; - let response_status = response.status(); - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - status = response_status.as_u16(), - prompt_chars = prompt.chars().count(), - size = %normalized_size, - reference_image_count = reference_images.len(), - elapsed_ms = started_at.elapsed().as_millis() as u64, - failure_context, - "VectorEngine 图片生成 HTTP 返回" - ); - let response_text = match response.text().await { - Ok(response_text) => response_text, - Err(error) => { - return Err(map_reqwest_error( - format!("{failure_context}:读取图片生成响应失败").as_str(), - request_url.as_str(), - "response_body", - error, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_images.len()), - )); - } - }; - handle_vector_engine_response( - http_client, - request_url.as_str(), - response_status.as_u16(), - response_text.as_str(), - failure_context, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_images.len()), - candidate_count, - "vector-engine", - ) - .await -} - -pub async fn create_vector_engine_image_edit( - http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - reference_image: &ReferenceImage, - failure_context: &str, -) -> Result { - create_vector_engine_image_edit_with_references( - http_client, - settings, - prompt, - negative_prompt, - size, - 1, - std::slice::from_ref(reference_image), - failure_context, - ) - .await -} - -pub async fn create_vector_engine_image_edit_with_references( - http_client: &reqwest::Client, - settings: &VectorEngineImageSettings, - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - candidate_count: u32, - reference_images: &[ReferenceImage], - failure_context: &str, -) -> Result { - if reference_images.is_empty() { - return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), - }); - } - - let request_url = vector_engine_images_edit_url(settings); - let normalized_size = normalize_image_size(size); - - let mut form = reqwest::multipart::Form::new() - .text("model", GPT_IMAGE_2_MODEL.to_string()) - .text("prompt", build_prompt_with_negative(prompt, negative_prompt)) - .text("n", candidate_count.clamp(1, 4).to_string()) - .text("size", normalized_size.clone()); - - for reference_image in reference_images.iter().take(5) { - 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| PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:构造参考图失败:{error}"), - })?; - form = form.part("image", image_part); - } - - let reference_image_count = reference_images.iter().take(5).count(); - let started_at = std::time::Instant::now(); - let response = match http_client - .post(request_url.as_str()) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .multipart(form) - .send() - .await - { - Ok(response) => response, - Err(error) => { - return Err(map_reqwest_error( - format!("{failure_context}:创建图片编辑任务失败").as_str(), - request_url.as_str(), - "request_send", - error, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_image_count), - )); - } - }; - let response_status = response.status(); - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - status = response_status.as_u16(), - prompt_chars = prompt.chars().count(), - size = %normalized_size, - reference_image_count, - elapsed_ms = started_at.elapsed().as_millis() as u64, - failure_context, - "VectorEngine 图片编辑 HTTP 返回" - ); - let response_text = match response.text().await { - Ok(response_text) => response_text, - Err(error) => { - return Err(map_reqwest_error( - format!("{failure_context}:读取图片编辑响应失败").as_str(), - request_url.as_str(), - "response_body", - error, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_image_count), - )); - } - }; - handle_vector_engine_response( - http_client, - request_url.as_str(), - response_status.as_u16(), - response_text.as_str(), - failure_context, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_image_count), - candidate_count, - "vector-engine-edit", - ) - .await -} - -#[allow(clippy::too_many_arguments)] -async fn handle_vector_engine_response( - http_client: &reqwest::Client, - request_url: &str, - response_status: u16, - response_text: &str, - failure_context: &str, - latency_ms: u64, - prompt_chars: Option, - reference_image_count: Option, - candidate_count: u32, - task_prefix: &str, -) -> Result { - if !(200..=299).contains(&response_status) { - let message = parse_api_error_message(response_text, failure_context); - let raw_excerpt = truncate_raw(response_text); - let audit = build_failure_audit( - request_url, - failure_context, - "upstream_status", - Some(response_status), - None, - false, - false, - message.as_str(), - None, - Some(raw_excerpt.clone()), - Some(latency_ms), - prompt_chars, - reference_image_count, - ); - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - upstream_status = response_status, - timeout = is_timeout_message(message.as_str()) || is_timeout_message(raw_excerpt.as_str()), - retryable = audit.retryable, - message = %message, - raw_excerpt = %raw_excerpt, - "VectorEngine 图片生成上游错误" - ); - return Err(PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, - message, - upstream_status: response_status, - raw_excerpt, - audit: Some(audit), - }); - } - - let response_json = match parse_json_payload(response_text, failure_context) { - Ok(response_json) => response_json, - Err(error) => { - let audit = build_failure_audit( - request_url, - failure_context, - "response_parse", - Some(response_status), - None, - false, - false, - error.message(), - None, - Some(truncate_raw(response_text)), - Some(latency_ms), - prompt_chars, - reference_image_count, - ); - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - status = response_status, - raw_excerpt = %truncate_raw(response_text), - message = %error.message(), - "VectorEngine 图片响应解析失败" - ); - return Err(error.with_audit(audit)); - } - }; - let task_id = extract_generation_id(&response_json.payload) - .unwrap_or_else(|| format!("{task_prefix}-{}", current_utc_micros())); - let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") - .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); - let image_urls = extract_image_urls(&response_json.payload); - if !image_urls.is_empty() { - let download_started_at = std::time::Instant::now(); - let mut generated = match download_images_from_urls( - http_client, - task_id, - image_urls, - candidate_count, - ) - .await - { - Ok(generated) => generated, - Err(error) => { - let audit = build_failure_audit( - request_url, - failure_context, - "image_download", - Some(response_status), - Some("5xx"), - false, - false, - error.message(), - None, - None, - Some(download_started_at.elapsed().as_millis() as u64), - prompt_chars, - reference_image_count, - ); - return Err(error.with_audit(audit)); - } - }; - generated.actual_prompt = actual_prompt; - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - image_count = generated.images.len(), - elapsed_ms = download_started_at.elapsed().as_millis() as u64, - failure_context, - "VectorEngine 图片下载完成" - ); - return Ok(generated); - } - let b64_images = extract_b64_images(&response_json.payload); - if !b64_images.is_empty() { - let mut generated = images_from_base64(task_id, b64_images, candidate_count); - generated.actual_prompt = actual_prompt; - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - image_count = generated.images.len(), - failure_context, - "VectorEngine 图片 base64 解码完成" - ); - return Ok(generated); - } - - let message = format!("{failure_context}:VectorEngine 未返回图片地址"); - let audit = build_failure_audit( - request_url, - failure_context, - "missing_image", - Some(response_status), - None, - false, - false, - message.as_str(), - None, - Some(truncate_raw(response_text)), - Some(latency_ms), - prompt_chars, - reference_image_count, - ); - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - status = response_status, - raw_excerpt = %truncate_raw(response_text), - "VectorEngine 图片响应未返回图片" - ); - Err(PlatformImageError::MissingImage { - provider: VECTOR_ENGINE_PROVIDER, - message, - audit: Some(audit), - }) -} - -pub fn build_vector_engine_image_request_body( - prompt: &str, - negative_prompt: Option<&str>, - size: &str, - candidate_count: u32, - _reference_images: &[String], -) -> Value { - let body = Map::from_iter([ - ( - "model".to_string(), - Value::String(GPT_IMAGE_2_MODEL.to_string()), - ), - ( - "prompt".to_string(), - Value::String(build_prompt_with_negative(prompt, negative_prompt)), - ), - ("n".to_string(), json!(candidate_count.clamp(1, 4))), - ( - "size".to_string(), - Value::String(normalize_image_size(size)), - ), - ]); - - Value::Object(body) -} - -pub fn normalize_image_size(size: &str) -> String { - match size.trim() { - "1024*1024" | "1024x1024" | "1:1" => "1024x1024", - "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152" - | "2k" => "1536x1024", - "1024*1536" | "1024x1536" | "9:16" => "1024x1536", - value if !value.is_empty() => value, - _ => "1024x1024", - } - .to_string() -} - -pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String { - if settings.base_url.ends_with("/v1") { - format!("{}/images/generations", settings.base_url) - } else { - format!("{}/v1/images/generations", settings.base_url) - } -} - -pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> String { - if settings.base_url.ends_with("/v1") { - format!("{}/images/edits", settings.base_url) - } else { - format!("{}/v1/images/edits", settings.base_url) - } -} - -pub async fn download_remote_image( - http_client: &reqwest::Client, - image_url: &str, -) -> Result { - let response = http_client.get(image_url).send().await.map_err(|error| { - map_simple_request_error(format!("下载生成图片失败:{error}"), Some(image_url.to_string())) - })?; - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/jpeg") - .to_string(); - let body = response.bytes().await.map_err(|error| { - map_simple_request_error(format!("读取生成图片内容失败:{error}"), Some(image_url.to_string())) - })?; - if !status.is_success() { - return Err(PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, - message: "下载生成图片失败".to_string(), - endpoint: Some(image_url.to_string()), - timeout: false, - connect: false, - request: false, - body: false, - status_code: Some(status.as_u16()), - source: None, - audit: None, - }); - } - - let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); - Ok(DownloadedImage { - extension: mime_to_extension(normalized_mime_type.as_str()).to_string(), - mime_type: normalized_mime_type, - bytes: body.to_vec(), - }) -} - -async fn download_images_from_urls( - http_client: &reqwest::Client, - task_id: String, - image_urls: Vec, - candidate_count: u32, -) -> Result { - let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); - for image_url in image_urls - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - { - images.push(download_remote_image(http_client, image_url.as_str()).await?); - } - Ok(GeneratedImages { - task_id, - actual_prompt: None, - images, - }) -} - -async fn resolve_reference_images( - http_client: &reqwest::Client, - reference_images: &[String], - failure_context: &str, -) -> Result, PlatformImageError> { - let mut resolved = Vec::new(); - for (index, source) in reference_images.iter().take(5).enumerate() { - let source = source.trim(); - if source.is_empty() { - continue; - } - if let Some(reference_image) = parse_reference_image_data_url(source, index)? { - resolved.push(reference_image); - continue; - } - if source.starts_with("http://") || source.starts_with("https://") { - let downloaded = download_remote_image(http_client, source) - .await - .map_err(|error| PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:下载参考图失败:{error}"), - endpoint: Some(source.to_string()), - timeout: false, - connect: false, - request: false, - body: false, - status_code: None, - source: None, - audit: None, - })?; - resolved.push(ReferenceImage { - bytes: downloaded.bytes, - mime_type: downloaded.mime_type.clone(), - file_name: format!( - "reference-{index}.{}", - mime_to_extension(downloaded.mime_type.as_str()) - ), - }); - continue; - } - return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), - }); - } - - if resolved.is_empty() { - return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:图片编辑需要至少一张参考图。"), - }); - } - - Ok(resolved) -} - -fn parse_reference_image_data_url( - source: &str, - index: usize, -) -> Result, PlatformImageError> { - let Some(body) = source.strip_prefix("data:") else { - return Ok(None); - }; - let Some((mime_type, data)) = body.split_once(";base64,") else { - return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: "参考图 Data URL 必须是 base64 图片。".to_string(), - }); - }; - if !mime_type.starts_with("image/") { - return Err(PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: "参考图 Data URL 必须是图片类型。".to_string(), - }); - } - let bytes = BASE64_STANDARD - .decode(data.trim()) - .map_err(|error| PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("参考图 Data URL 解码失败:{error}"), - })?; - let mime_type = normalize_downloaded_image_mime_type(mime_type); - Ok(Some(ReferenceImage { - bytes, - file_name: format!( - "reference-{index}.{}", - mime_to_extension(mime_type.as_str()) - ), - mime_type, - })) -} - -fn images_from_base64( - task_id: String, - b64_images: Vec, - candidate_count: u32, -) -> GeneratedImages { - let images = b64_images - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - .filter_map(|raw| decode_generated_image_base64(raw.as_str())) - .collect(); - - GeneratedImages { - task_id, - actual_prompt: None, - images, - } -} - -fn decode_generated_image_base64(raw: &str) -> Option { - let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; - let mime_type = infer_image_mime_type(bytes.as_slice()); - Some(DownloadedImage { - extension: mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes, - }) -} - -fn parse_json_payload( - raw_text: &str, - failure_context: &str, -) -> Result { - serde_json::from_str::(raw_text) - .map(|payload| ParsedJsonPayload { payload }) - .map_err(|error| PlatformImageError::ResponseParse { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:解析响应失败:{error}"), - raw_excerpt: truncate_raw(raw_text), - audit: None, - }) -} - -fn map_reqwest_error( - context: &str, - request_url: &str, - failure_stage: &'static str, - error: reqwest::Error, - latency_ms: u64, - prompt_chars: Option, - reference_image_count: Option, -) -> PlatformImageError { - let is_timeout = error.is_timeout(); - let is_connect = error.is_connect(); - let source = error.source().map(ToString::to_string); - let message = format!("{context}:{error}"); - let audit = build_failure_audit( - request_url, - context, - failure_stage, - error.status().map(|status| status.as_u16()), - None, - is_timeout, - is_connect, - message.as_str(), - source.clone(), - None, - Some(latency_ms), - prompt_chars, - reference_image_count, - ); - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - failure_stage, - timeout = is_timeout, - connect = is_connect, - request = error.is_request(), - body = error.is_body(), - status = error.status().map(|status| status.as_u16()).unwrap_or_default(), - source = %source.clone().unwrap_or_default(), - message = %message, - elapsed_ms = latency_ms, - prompt_chars, - reference_image_count, - "VectorEngine 图片请求发送失败" - ); - - PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, - message, - endpoint: Some(request_url.to_string()), - timeout: is_timeout, - connect: is_connect, - request: error.is_request(), - body: error.is_body(), - status_code: error.status().map(|status| status.as_u16()), - source, - audit: Some(audit), - } -} - -fn map_simple_request_error(message: String, endpoint: Option) -> PlatformImageError { - PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, - message, - endpoint, - timeout: false, - connect: false, - request: true, - body: false, - status_code: None, - source: None, - audit: None, - } -} - -#[allow(clippy::too_many_arguments)] -fn build_failure_audit( - request_url: &str, - operation: &str, - failure_stage: &'static str, - status_code: Option, - status_class: Option<&'static str>, - timeout: bool, - connect: bool, - error_message: &str, - error_source: Option, - raw_excerpt: Option, - latency_ms: Option, - prompt_chars: Option, - reference_image_count: Option, -) -> PlatformImageFailureAudit { - PlatformImageFailureAudit { - provider: VECTOR_ENGINE_PROVIDER, - endpoint: request_url.to_string(), - operation: operation.to_string(), - failure_stage, - status_code, - status_class, - timeout, - retryable: is_retryable_external_api_failure(status_code, timeout, connect), - error_message: error_message.to_string(), - error_source, - raw_excerpt, - latency_ms, - prompt_chars, - reference_image_count, - image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), - } -} - -fn is_retryable_external_api_failure( - status_code: Option, - timeout: bool, - connect: bool, -) -> bool { - timeout || connect || status_code.is_some_and(|status| status == 429 || status == 408 || status >= 500) -} - -fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String { - let prompt = prompt.trim(); - let Some(negative_prompt) = negative_prompt - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return prompt.to_string(); - }; - - format!("{prompt}\n避免:{negative_prompt}") -} - -fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { - if raw_text.trim().is_empty() { - return fallback_message.to_string(); - } - - if let Ok(parsed) = serde_json::from_str::(raw_text) { - for pointer in [ - "/error/message", - "/message", - "/output/message", - "/data/message", - ] { - if let Some(message) = parsed - .pointer(pointer) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return message.to_string(); - } - } - for pointer in ["/error/code", "/code", "/output/code", "/data/code"] { - if let Some(code) = parsed - .pointer(pointer) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return format!("{fallback_message}({code})"); - } - } - } - - raw_text.trim().to_string() -} - -fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { - match value { - Value::Array(entries) => { - for entry in entries { - collect_strings_by_key(entry, target_key, results); - } - } - Value::Object(object) => { - for (key, nested_value) in object { - if key == target_key { - match nested_value { - Value::String(text) => { - let text = text.trim(); - if !text.is_empty() { - results.push(text.to_string()); - continue; - } - } - Value::Array(entries) => { - for entry in entries { - if let Some(text) = entry - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - results.push(text.to_string()); - } - } - } - _ => {} - } - } - collect_strings_by_key(nested_value, target_key, results); - } - } - _ => {} - } -} - -fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { - let mut results = Vec::new(); - collect_strings_by_key(value, target_key, &mut results); - results.into_iter().next() -} - -fn extract_generation_id(payload: &Value) -> Option { - find_first_string_by_key(payload, "id") - .or_else(|| find_first_string_by_key(payload, "created")) - .or_else(|| find_first_string_by_key(payload, "request_id")) -} - -fn extract_image_urls(payload: &Value) -> Vec { - let mut urls = Vec::new(); - collect_strings_by_key(payload, "url", &mut urls); - collect_strings_by_key(payload, "image", &mut urls); - collect_strings_by_key(payload, "image_url", &mut urls); - let mut deduped = Vec::new(); - for url in urls { - if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { - deduped.push(url); - } - } - deduped -} - -fn extract_b64_images(payload: &Value) -> Vec { - let mut values = Vec::new(); - collect_strings_by_key(payload, "b64_json", &mut values); - values -} - -fn normalize_downloaded_image_mime_type(content_type: &str) -> String { - let mime_type = content_type - .split(';') - .next() - .map(str::trim) - .unwrap_or("image/jpeg"); - match mime_type { - "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { - mime_type.to_string() - } - _ => "image/jpeg".to_string(), - } -} - -fn mime_to_extension(mime_type: &str) -> &str { - match mime_type { - "image/png" => "png", - "image/webp" => "webp", - "image/gif" => "gif", - _ => "jpg", - } -} - -fn infer_image_mime_type(bytes: &[u8]) -> String { - if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { - return "image/png".to_string(); - } - if bytes.starts_with(b"\xFF\xD8\xFF") { - return "image/jpeg".to_string(); - } - if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { - return "image/webp".to_string(); - } - if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { - return "image/gif".to_string(); - } - "image/png".to_string() -} - -fn is_timeout_message(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("timed out") - || lower.contains("timeout") - || lower.contains("operation timed out") - || lower.contains("deadline has elapsed") -} - -fn truncate_raw(raw_text: &str) -> String { - raw_text.chars().take(800).collect() -} - -fn current_utc_micros() -> i64 { - use std::time::{SystemTime, UNIX_EPOCH}; - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch"); - i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") -} - -impl PlatformImageError { - fn with_audit(self, audit: PlatformImageFailureAudit) -> Self { - match self { - Self::Request { - provider, - message, - endpoint, - timeout, - connect, - request, - body, - status_code, - source, - .. - } => Self::Request { - provider, - message, - endpoint, - timeout, - connect, - request, - body, - status_code, - source, - audit: Some(audit), - }, - Self::Upstream { - provider, - message, - upstream_status, - raw_excerpt, - .. - } => Self::Upstream { - provider, - message, - upstream_status, - raw_excerpt, - audit: Some(audit), - }, - Self::ResponseParse { - provider, - message, - raw_excerpt, - .. - } => Self::ResponseParse { - provider, - message, - raw_excerpt, - audit: Some(audit), - }, - Self::MissingImage { - provider, message, .. - } => Self::MissingImage { - provider, - message, - audit: Some(audit), - }, - Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => self, - } - } -} - -struct ParsedJsonPayload { - payload: Value, -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - use serde_json::json; - - #[test] - fn request_body_normalizes_size_prompt_and_candidate_count() { - let body = build_vector_engine_image_request_body( - " 风雨夜里的街道 ", - Some(" 低清,水印 "), - " 1:1 ", - 10, - &["data:image/png;base64,AAAA".to_string()], - ); - - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); - assert_eq!(body["size"], "1024x1024"); - assert_eq!(body["n"], 4); - assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印"); - assert!(body.get("image").is_none()); - } - - #[test] - fn provider_urls_normalize_root_and_v1_base_urls() { - let root_settings = VectorEngineImageSettings { - base_url: "https://vector.example".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000, - }; - let v1_settings = VectorEngineImageSettings { - base_url: "https://vector.example/v1".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000, - }; - - assert_eq!( - vector_engine_images_generation_url(&root_settings), - "https://vector.example/v1/images/generations" - ); - assert_eq!( - vector_engine_images_generation_url(&v1_settings), - "https://vector.example/v1/images/generations" - ); - assert_eq!( - vector_engine_images_edit_url(&root_settings), - "https://vector.example/v1/images/edits" - ); - assert_eq!( - vector_engine_images_edit_url(&v1_settings), - "https://vector.example/v1/images/edits" - ); - } - - #[test] - fn data_url_and_base64_image_decoding_preserves_image_metadata() { - let data_url = format!( - "data:image/png;base64,{}", - BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") - ); - - let reference = parse_reference_image_data_url(&data_url, 2) - .expect("data url should parse") - .expect("image data url should be accepted"); - assert_eq!(reference.file_name, "reference-2.png"); - assert_eq!(reference.mime_type, "image/png"); - assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest"); - - let image = decode_generated_image_base64(BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str()) - .expect("base64 image should decode"); - assert_eq!(image.extension, "png"); - assert_eq!(image.mime_type, "image/png"); - assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest"); - } - - #[test] - fn error_status_hints_and_audit_fields_are_structured() { - let audit = PlatformImageFailureAudit { - provider: VECTOR_ENGINE_PROVIDER, - endpoint: "https://vector.example/v1/images/generations".to_string(), - operation: "图片生成失败".to_string(), - failure_stage: "upstream_status", - status_code: Some(504), - status_class: Some("5xx"), - timeout: true, - retryable: true, - error_message: "上游超时".to_string(), - error_source: Some("read timeout".to_string()), - raw_excerpt: Some("{\"error\":\"timeout\"}".to_string()), - latency_ms: Some(987), - prompt_chars: Some(64), - reference_image_count: Some(2), - image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), - }; - - let request_error = PlatformImageError::Request { - provider: VECTOR_ENGINE_PROVIDER, - message: "请求发送失败".to_string(), - endpoint: Some("https://vector.example/v1/images/generations".to_string()), - timeout: true, - connect: false, - request: true, - body: false, - status_code: None, - source: None, - audit: None, - }; - let invalid_config = PlatformImageError::InvalidConfig { - provider: VECTOR_ENGINE_PROVIDER, - message: "缺少配置".to_string(), - }; - let invalid_request = PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: "请求不合法".to_string(), - }; - let upstream_timeout = PlatformImageError::Upstream { - provider: VECTOR_ENGINE_PROVIDER, - message: "upstream timeout".to_string(), - upstream_status: 502, - raw_excerpt: "deadline has elapsed".to_string(), - audit: Some(audit.clone()), - }; - - assert_eq!(invalid_config.status_hint(), PlatformImageStatusHint::ServiceUnavailable); - assert_eq!(invalid_request.status_hint(), PlatformImageStatusHint::BadRequest); - assert_eq!(request_error.status_hint(), PlatformImageStatusHint::GatewayTimeout); - assert_eq!(upstream_timeout.status_hint(), PlatformImageStatusHint::GatewayTimeout); - assert_eq!( - PlatformImageError::MissingImage { - provider: VECTOR_ENGINE_PROVIDER, - message: "缺图".to_string(), - audit: Some(audit.clone()), - } - .status_hint(), - PlatformImageStatusHint::BadGateway - ); - - let audit_ref = upstream_timeout.audit().expect("audit should be preserved"); - assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER); - assert_eq!(audit_ref.endpoint, "https://vector.example/v1/images/generations"); - assert_eq!(audit_ref.status_code, Some(504)); - assert_eq!(audit_ref.status_class, Some("5xx")); - assert!(audit_ref.timeout); - assert!(audit_ref.retryable); - assert_eq!(audit_ref.reference_image_count, Some(2)); - assert_eq!(audit_ref.image_model, Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)); - assert!(invalid_config.audit().is_none()); - assert!(invalid_request.audit().is_none()); - } - - #[test] - fn extract_image_urls_and_b64_values_are_deduped() { - let payload = json!({ - "data": [ - {"image": "https://example.com/a.png"}, - {"url": "https://example.com/a.png"}, - {"image_url": "ftp://example.com/b.png"}, - {"url": "https://example.com/b.png"} - ], - "nested": { - "b64_json": ["YWJj", "ZGVm"] - } - }); - - assert_eq!( - extract_image_urls(&payload), - vec![ - "https://example.com/a.png".to_string(), - "https://example.com/b.png".to_string() - ] - ); - assert_eq!( - extract_b64_images(&payload), - vec!["YWJj".to_string(), "ZGVm".to_string()] - ); - } -} +pub mod generated_asset_sheets; +pub mod generated_assets; +pub mod vector_engine; + +pub use vector_engine::{ + DownloadedImage, GPT_IMAGE_2_MODEL, GeneratedImages, PlatformImageError, + PlatformImageFailureAudit, PlatformImageStatusHint, ReferenceImage, + VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, + build_vector_engine_image_http_client, build_vector_engine_image_request_body, + create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, + create_vector_engine_image_generation, download_remote_image, vector_engine_images_edit_url, + vector_engine_images_generation_url, +}; diff --git a/server-rs/crates/platform-image/src/vector_engine/audit.rs b/server-rs/crates/platform-image/src/vector_engine/audit.rs new file mode 100644 index 00000000..c28381d5 --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/audit.rs @@ -0,0 +1,64 @@ +use super::constants::{VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER}; + +#[derive(Clone, Debug)] +pub struct PlatformImageFailureAudit { + pub provider: &'static str, + pub endpoint: String, + pub operation: String, + pub failure_stage: &'static str, + pub status_code: Option, + pub status_class: Option<&'static str>, + pub timeout: bool, + pub retryable: bool, + pub error_message: String, + pub error_source: Option, + pub raw_excerpt: Option, + pub latency_ms: Option, + pub prompt_chars: Option, + pub reference_image_count: Option, + pub image_model: Option<&'static str>, +} + +pub(crate) fn build_failure_audit( + request_url: &str, + operation: &str, + failure_stage: &'static str, + status_code: Option, + status_class: Option<&'static str>, + timeout: bool, + connect: bool, + error_message: &str, + error_source: Option, + raw_excerpt: Option, + latency_ms: Option, + prompt_chars: Option, + reference_image_count: Option, +) -> PlatformImageFailureAudit { + PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: request_url.to_string(), + operation: operation.to_string(), + failure_stage, + status_code, + status_class, + timeout, + retryable: is_retryable_external_api_failure(status_code, timeout, connect), + error_message: error_message.to_string(), + error_source, + raw_excerpt, + latency_ms, + prompt_chars, + reference_image_count, + image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + } +} + +pub(crate) fn is_retryable_external_api_failure( + status_code: Option, + timeout: bool, + connect: bool, +) -> bool { + timeout + || connect + || status_code.is_some_and(|status| status == 429 || status == 408 || status >= 500) +} diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs new file mode 100644 index 00000000..b7a31084 --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -0,0 +1,245 @@ +use reqwest::header; + +use super::{ + constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER}, + error::PlatformImageError, + image_source::resolve_reference_images, + request::{ + build_prompt_with_negative, build_vector_engine_image_request_body, normalize_image_size, + vector_engine_images_edit_url, vector_engine_images_generation_url, + }, + response::handle_vector_engine_response, + transport::map_reqwest_error, + types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, +}; + +pub async fn create_vector_engine_image_generation( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[String], + failure_context: &str, +) -> Result { + if !reference_images.is_empty() { + let resolved_references = + resolve_reference_images(http_client, reference_images, failure_context).await?; + return create_vector_engine_image_edit_with_references( + http_client, + settings, + prompt, + negative_prompt, + size, + candidate_count, + resolved_references.as_slice(), + failure_context, + ) + .await; + } + + let request_url = vector_engine_images_generation_url(settings); + let normalized_size = normalize_image_size(size); + let request_body = build_vector_engine_image_request_body( + prompt, + negative_prompt, + normalized_size.as_str(), + candidate_count, + reference_images, + ); + let started_at = std::time::Instant::now(); + let response = match http_client + .post(request_url.as_str()) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json") + .json(&request_body) + .send() + .await + { + Ok(response) => response, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:创建图片生成任务失败").as_str(), + request_url.as_str(), + "request_send", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + )); + } + }; + let response_status = response.status(); + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status.as_u16(), + prompt_chars = prompt.chars().count(), + size = %normalized_size, + reference_image_count = reference_images.len(), + elapsed_ms = started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片生成 HTTP 返回" + ); + let response_text = match response.text().await { + Ok(response_text) => response_text, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:读取图片生成响应失败").as_str(), + request_url.as_str(), + "response_body", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + )); + } + }; + handle_vector_engine_response( + http_client, + request_url.as_str(), + response_status.as_u16(), + response_text.as_str(), + failure_context, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + candidate_count, + "vector-engine", + ) + .await +} + +pub async fn create_vector_engine_image_edit( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + reference_image: &ReferenceImage, + failure_context: &str, +) -> Result { + create_vector_engine_image_edit_with_references( + http_client, + settings, + prompt, + negative_prompt, + size, + 1, + std::slice::from_ref(reference_image), + failure_context, + ) + .await +} + +pub async fn create_vector_engine_image_edit_with_references( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[ReferenceImage], + failure_context: &str, +) -> Result { + if reference_images.is_empty() { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), + }); + } + + let request_url = vector_engine_images_edit_url(settings); + let normalized_size = normalize_image_size(size); + + let mut form = reqwest::multipart::Form::new() + .text("model", GPT_IMAGE_2_MODEL.to_string()) + .text( + "prompt", + build_prompt_with_negative(prompt, negative_prompt), + ) + .text("n", candidate_count.clamp(1, 4).to_string()) + .text("size", normalized_size.clone()); + + for reference_image in reference_images.iter().take(5) { + 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| PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:构造参考图失败:{error}"), + })?; + form = form.part("image", image_part); + } + + let reference_image_count = reference_images.iter().take(5).count(); + let started_at = std::time::Instant::now(); + let response = match http_client + .post(request_url.as_str()) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .multipart(form) + .send() + .await + { + Ok(response) => response, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:创建图片编辑任务失败").as_str(), + request_url.as_str(), + "request_send", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + )); + } + }; + let response_status = response.status(); + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status.as_u16(), + prompt_chars = prompt.chars().count(), + size = %normalized_size, + reference_image_count, + elapsed_ms = started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片编辑 HTTP 返回" + ); + let response_text = match response.text().await { + Ok(response_text) => response_text, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:读取图片编辑响应失败").as_str(), + request_url.as_str(), + "response_body", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + )); + } + }; + handle_vector_engine_response( + http_client, + request_url.as_str(), + response_status.as_u16(), + response_text.as_str(), + failure_context, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + candidate_count, + "vector-engine-edit", + ) + .await +} diff --git a/server-rs/crates/platform-image/src/vector_engine/constants.rs b/server-rs/crates/platform-image/src/vector_engine/constants.rs new file mode 100644 index 00000000..4cfa0432 --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/constants.rs @@ -0,0 +1,3 @@ +pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; +pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; +pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; diff --git a/server-rs/crates/platform-image/src/vector_engine/error.rs b/server-rs/crates/platform-image/src/vector_engine/error.rs new file mode 100644 index 00000000..c98edf2d --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/error.rs @@ -0,0 +1,114 @@ +use std::{error::Error, fmt}; + +use super::{audit::PlatformImageFailureAudit, util::is_timeout_message}; + +#[derive(Clone, Debug)] +pub enum PlatformImageError { + InvalidConfig { + provider: &'static str, + message: String, + }, + InvalidRequest { + provider: &'static str, + message: String, + }, + Request { + provider: &'static str, + message: String, + endpoint: Option, + timeout: bool, + connect: bool, + request: bool, + body: bool, + status_code: Option, + source: Option, + audit: Option, + }, + Upstream { + provider: &'static str, + message: String, + upstream_status: u16, + raw_excerpt: String, + audit: Option, + }, + ResponseParse { + provider: &'static str, + message: String, + raw_excerpt: String, + audit: Option, + }, + MissingImage { + provider: &'static str, + message: String, + audit: Option, + }, +} + +impl PlatformImageError { + pub fn provider(&self) -> &'static str { + match self { + Self::InvalidConfig { provider, .. } + | Self::InvalidRequest { provider, .. } + | Self::Request { provider, .. } + | Self::Upstream { provider, .. } + | Self::ResponseParse { provider, .. } + | Self::MissingImage { provider, .. } => provider, + } + } + + pub fn message(&self) -> &str { + match self { + Self::InvalidConfig { message, .. } + | Self::InvalidRequest { message, .. } + | Self::Request { message, .. } + | Self::Upstream { message, .. } + | Self::ResponseParse { message, .. } + | Self::MissingImage { message, .. } => message, + } + } + + pub fn audit(&self) -> Option<&PlatformImageFailureAudit> { + match self { + Self::Request { audit, .. } + | Self::Upstream { audit, .. } + | Self::ResponseParse { audit, .. } + | Self::MissingImage { audit, .. } => audit.as_ref(), + Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => None, + } + } + + pub fn status_hint(&self) -> PlatformImageStatusHint { + match self { + Self::InvalidConfig { .. } => PlatformImageStatusHint::ServiceUnavailable, + Self::InvalidRequest { .. } => PlatformImageStatusHint::BadRequest, + Self::Request { timeout, .. } if *timeout => PlatformImageStatusHint::GatewayTimeout, + Self::Upstream { + message, + raw_excerpt, + .. + } if is_timeout_message(message) || is_timeout_message(raw_excerpt) => { + PlatformImageStatusHint::GatewayTimeout + } + Self::Request { .. } + | Self::Upstream { .. } + | Self::ResponseParse { .. } + | Self::MissingImage { .. } => PlatformImageStatusHint::BadGateway, + } + } +} + +impl fmt::Display for PlatformImageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message()) + } +} + +impl Error for PlatformImageError {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlatformImageStatusHint { + BadRequest, + ServiceUnavailable, + BadGateway, + GatewayTimeout, +} diff --git a/server-rs/crates/platform-image/src/vector_engine/image_source.rs b/server-rs/crates/platform-image/src/vector_engine/image_source.rs new file mode 100644 index 00000000..f04decf5 --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/image_source.rs @@ -0,0 +1,248 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use reqwest::header; + +use super::{ + constants::VECTOR_ENGINE_PROVIDER, + error::PlatformImageError, + types::{DownloadedImage, GeneratedImages, ReferenceImage}, +}; + +pub async fn download_remote_image( + http_client: &reqwest::Client, + image_url: &str, +) -> Result { + let response = http_client.get(image_url).send().await.map_err(|error| { + map_simple_request_error( + format!("下载生成图片失败:{error}"), + Some(image_url.to_string()), + ) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + let body = response.bytes().await.map_err(|error| { + map_simple_request_error( + format!("读取生成图片内容失败:{error}"), + Some(image_url.to_string()), + ) + })?; + if !status.is_success() { + return Err(PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: "下载生成图片失败".to_string(), + endpoint: Some(image_url.to_string()), + timeout: false, + connect: false, + request: false, + body: false, + status_code: Some(status.as_u16()), + source: None, + audit: None, + }); + } + + let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); + Ok(DownloadedImage { + extension: mime_to_extension(normalized_mime_type.as_str()).to_string(), + mime_type: normalized_mime_type, + bytes: body.to_vec(), + }) +} + +pub(crate) async fn download_images_from_urls( + http_client: &reqwest::Client, + task_id: String, + image_urls: Vec, + candidate_count: u32, +) -> Result { + let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + { + images.push(download_remote_image(http_client, image_url.as_str()).await?); + } + Ok(GeneratedImages { + task_id, + actual_prompt: None, + images, + }) +} + +pub(crate) async fn resolve_reference_images( + http_client: &reqwest::Client, + reference_images: &[String], + failure_context: &str, +) -> Result, PlatformImageError> { + let mut resolved = Vec::new(); + for (index, source) in reference_images.iter().take(5).enumerate() { + let source = source.trim(); + if source.is_empty() { + continue; + } + if let Some(reference_image) = parse_reference_image_data_url(source, index)? { + resolved.push(reference_image); + continue; + } + if source.starts_with("http://") || source.starts_with("https://") { + let downloaded = download_remote_image(http_client, source) + .await + .map_err(|error| PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:下载参考图失败:{error}"), + endpoint: Some(source.to_string()), + timeout: false, + connect: false, + request: false, + body: false, + status_code: None, + source: None, + audit: None, + })?; + resolved.push(ReferenceImage { + bytes: downloaded.bytes, + mime_type: downloaded.mime_type.clone(), + file_name: format!( + "reference-{index}.{}", + mime_to_extension(downloaded.mime_type.as_str()) + ), + }); + continue; + } + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), + }); + } + + if resolved.is_empty() { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:图片编辑需要至少一张参考图。"), + }); + } + + Ok(resolved) +} + +pub(crate) fn parse_reference_image_data_url( + source: &str, + index: usize, +) -> Result, PlatformImageError> { + let Some(body) = source.strip_prefix("data:") else { + return Ok(None); + }; + let Some((mime_type, data)) = body.split_once(";base64,") else { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "参考图 Data URL 必须是 base64 图片。".to_string(), + }); + }; + if !mime_type.starts_with("image/") { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "参考图 Data URL 必须是图片类型。".to_string(), + }); + } + let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { + PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("参考图 Data URL 解码失败:{error}"), + } + })?; + let mime_type = normalize_downloaded_image_mime_type(mime_type); + Ok(Some(ReferenceImage { + bytes, + file_name: format!( + "reference-{index}.{}", + mime_to_extension(mime_type.as_str()) + ), + mime_type, + })) +} + +pub(crate) fn images_from_base64( + task_id: String, + b64_images: Vec, + candidate_count: u32, +) -> GeneratedImages { + let images = b64_images + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + .filter_map(|raw| decode_generated_image_base64(raw.as_str())) + .collect(); + + GeneratedImages { + task_id, + actual_prompt: None, + images, + } +} + +pub(crate) fn decode_generated_image_base64(raw: &str) -> Option { + let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; + let mime_type = infer_image_mime_type(bytes.as_slice()); + Some(DownloadedImage { + extension: mime_to_extension(mime_type.as_str()).to_string(), + mime_type, + bytes, + }) +} + +pub(crate) fn normalize_downloaded_image_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("image/jpeg"); + match mime_type { + "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { + mime_type.to_string() + } + _ => "image/jpeg".to_string(), + } +} + +pub(crate) fn mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "jpg", + } +} + +pub(crate) fn infer_image_mime_type(bytes: &[u8]) -> String { + if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { + return "image/png".to_string(); + } + if bytes.starts_with(b"\xFF\xD8\xFF") { + return "image/jpeg".to_string(); + } + if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + return "image/webp".to_string(); + } + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return "image/gif".to_string(); + } + "image/png".to_string() +} + +fn map_simple_request_error(message: String, endpoint: Option) -> PlatformImageError { + PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message, + endpoint, + timeout: false, + connect: false, + request: true, + body: false, + status_code: None, + source: None, + audit: None, + } +} diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs new file mode 100644 index 00000000..6cdcf543 --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs @@ -0,0 +1,26 @@ +mod audit; +mod client; +mod constants; +mod error; +mod image_source; +mod payload; +mod request; +mod response; +mod transport; +mod types; +mod util; + +pub use audit::PlatformImageFailureAudit; +pub use client::{ + create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, + create_vector_engine_image_generation, +}; +pub use constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER}; +pub use error::{PlatformImageError, PlatformImageStatusHint}; +pub use image_source::download_remote_image; +pub use request::{ + build_vector_engine_image_request_body, normalize_image_size, vector_engine_images_edit_url, + vector_engine_images_generation_url, +}; +pub use transport::build_vector_engine_image_http_client; +pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings}; diff --git a/server-rs/crates/platform-image/src/vector_engine/payload.rs b/server-rs/crates/platform-image/src/vector_engine/payload.rs new file mode 100644 index 00000000..f7b4a8e6 --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/payload.rs @@ -0,0 +1,128 @@ +use serde_json::Value; + +use super::{ + constants::VECTOR_ENGINE_PROVIDER, + error::PlatformImageError, + util::{ParsedJsonPayload, truncate_raw}, +}; + +pub(super) fn parse_json_payload( + raw_text: &str, + failure_context: &str, +) -> Result { + serde_json::from_str::(raw_text) + .map(|payload| ParsedJsonPayload { payload }) + .map_err(|error| PlatformImageError::ResponseParse { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:解析响应失败:{error}"), + raw_excerpt: truncate_raw(raw_text), + audit: None, + }) +} + +fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { + match value { + Value::Array(entries) => { + for entry in entries { + collect_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, nested_value) in object { + if key == target_key { + match nested_value { + Value::String(text) => { + let text = text.trim(); + if !text.is_empty() { + results.push(text.to_string()); + continue; + } + } + Value::Array(entries) => { + for entry in entries { + if let Some(text) = entry + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(text.to_string()); + } + } + } + _ => {} + } + } + collect_strings_by_key(nested_value, target_key, results); + } + } + _ => {} + } +} + +pub(super) fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_strings_by_key(value, target_key, &mut results); + results.into_iter().next() +} + +pub(super) fn extract_generation_id(payload: &Value) -> Option { + find_first_string_by_key(payload, "id") + .or_else(|| find_first_string_by_key(payload, "created")) + .or_else(|| find_first_string_by_key(payload, "request_id")) +} + +pub(super) fn extract_image_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_strings_by_key(payload, "url", &mut urls); + collect_strings_by_key(payload, "image", &mut urls); + collect_strings_by_key(payload, "image_url", &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +pub(super) fn extract_b64_images(payload: &Value) -> Vec { + let mut values = Vec::new(); + collect_strings_by_key(payload, "b64_json", &mut values); + values +} + +pub(super) fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { + if raw_text.trim().is_empty() { + return fallback_message.to_string(); + } + + if let Ok(parsed) = serde_json::from_str::(raw_text) { + for pointer in [ + "/error/message", + "/message", + "/output/message", + "/data/message", + ] { + if let Some(message) = parsed + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + } + for pointer in ["/error/code", "/code", "/output/code", "/data/code"] { + if let Some(code) = parsed + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("{fallback_message}({code})"); + } + } + } + + raw_text.trim().to_string() +} diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs new file mode 100644 index 00000000..10a5c06b --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -0,0 +1,69 @@ +use serde_json::{Map, Value, json}; + +use super::{constants::GPT_IMAGE_2_MODEL, types::VectorEngineImageSettings}; + +pub fn build_vector_engine_image_request_body( + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + _reference_images: &[String], +) -> Value { + let body = Map::from_iter([ + ( + "model".to_string(), + Value::String(GPT_IMAGE_2_MODEL.to_string()), + ), + ( + "prompt".to_string(), + Value::String(build_prompt_with_negative(prompt, negative_prompt)), + ), + ("n".to_string(), json!(candidate_count.clamp(1, 4))), + ( + "size".to_string(), + Value::String(normalize_image_size(size)), + ), + ]); + + Value::Object(body) +} + +pub fn normalize_image_size(size: &str) -> String { + match size.trim() { + "1024*1024" | "1024x1024" | "1:1" => "1024x1024", + "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152" + | "2k" => "1536x1024", + "1024*1536" | "1024x1536" | "9:16" => "1024x1536", + value if !value.is_empty() => value, + _ => "1024x1024", + } + .to_string() +} + +pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/generations", settings.base_url) + } else { + format!("{}/v1/images/generations", settings.base_url) + } +} + +pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/edits", settings.base_url) + } else { + format!("{}/v1/images/edits", settings.base_url) + } +} + +pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String { + let prompt = prompt.trim(); + let Some(negative_prompt) = negative_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return prompt.to_string(); + }; + + format!("{prompt}\n避免:{negative_prompt}") +} diff --git a/server-rs/crates/platform-image/src/vector_engine/response.rs b/server-rs/crates/platform-image/src/vector_engine/response.rs new file mode 100644 index 00000000..2d98918e --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/response.rs @@ -0,0 +1,180 @@ +use super::{ + audit::build_failure_audit, + constants::VECTOR_ENGINE_PROVIDER, + error::PlatformImageError, + image_source::{download_images_from_urls, images_from_base64}, + payload::{ + extract_b64_images, extract_generation_id, extract_image_urls, find_first_string_by_key, + parse_api_error_message, parse_json_payload, + }, + types::GeneratedImages, + util::{current_utc_micros, is_timeout_message, truncate_raw}, +}; + +pub(crate) async fn handle_vector_engine_response( + http_client: &reqwest::Client, + request_url: &str, + response_status: u16, + response_text: &str, + failure_context: &str, + latency_ms: u64, + prompt_chars: Option, + reference_image_count: Option, + candidate_count: u32, + task_prefix: &str, +) -> Result { + if !(200..=299).contains(&response_status) { + let message = parse_api_error_message(response_text, failure_context); + let raw_excerpt = truncate_raw(response_text); + let audit = build_failure_audit( + request_url, + failure_context, + "upstream_status", + Some(response_status), + None, + false, + false, + message.as_str(), + None, + Some(raw_excerpt.clone()), + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + upstream_status = response_status, + timeout = is_timeout_message(message.as_str()) || is_timeout_message(raw_excerpt.as_str()), + retryable = audit.retryable, + message = %message, + raw_excerpt = %raw_excerpt, + "VectorEngine 图片生成上游错误" + ); + return Err(PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message, + upstream_status: response_status, + raw_excerpt, + audit: Some(audit), + }); + } + + let response_json = match parse_json_payload(response_text, failure_context) { + Ok(response_json) => response_json, + Err(error) => { + let audit = build_failure_audit( + request_url, + failure_context, + "response_parse", + Some(response_status), + None, + false, + false, + error.message(), + None, + Some(truncate_raw(response_text)), + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status, + raw_excerpt = %truncate_raw(response_text), + message = %error.message(), + "VectorEngine 图片响应解析失败" + ); + return Err(error.with_audit(audit)); + } + }; + let task_id = extract_generation_id(&response_json.payload) + .unwrap_or_else(|| format!("{task_prefix}-{}", current_utc_micros())); + let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") + .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); + let image_urls = extract_image_urls(&response_json.payload); + if !image_urls.is_empty() { + let download_started_at = std::time::Instant::now(); + let mut generated = match download_images_from_urls( + http_client, + task_id, + image_urls, + candidate_count, + ) + .await + { + Ok(generated) => generated, + Err(error) => { + let audit = build_failure_audit( + request_url, + failure_context, + "image_download", + Some(response_status), + Some("5xx"), + false, + false, + error.message(), + None, + None, + Some(download_started_at.elapsed().as_millis() as u64), + prompt_chars, + reference_image_count, + ); + return Err(error.with_audit(audit)); + } + }; + generated.actual_prompt = actual_prompt; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + image_count = generated.images.len(), + elapsed_ms = download_started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片下载完成" + ); + return Ok(generated); + } + let b64_images = extract_b64_images(&response_json.payload); + if !b64_images.is_empty() { + let mut generated = images_from_base64(task_id, b64_images, candidate_count); + generated.actual_prompt = actual_prompt; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + image_count = generated.images.len(), + failure_context, + "VectorEngine 图片 base64 解码完成" + ); + return Ok(generated); + } + + let message = format!("{failure_context}:VectorEngine 未返回图片地址"); + let audit = build_failure_audit( + request_url, + failure_context, + "missing_image", + Some(response_status), + None, + false, + false, + message.as_str(), + None, + Some(truncate_raw(response_text)), + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status, + raw_excerpt = %truncate_raw(response_text), + "VectorEngine 图片响应未返回图片" + ); + Err(PlatformImageError::MissingImage { + provider: VECTOR_ENGINE_PROVIDER, + message, + audit: Some(audit), + }) +} diff --git a/server-rs/crates/platform-image/src/vector_engine/tests.rs b/server-rs/crates/platform-image/src/vector_engine/tests.rs new file mode 100644 index 00000000..1f1d6691 --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/tests.rs @@ -0,0 +1,177 @@ +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use serde_json::json; + + #[test] + fn request_body_normalizes_size_prompt_and_candidate_count() { + let body = build_vector_engine_image_request_body( + " 风雨夜里的街道 ", + Some(" 低清,水印 "), + " 1:1 ", + 10, + &["data:image/png;base64,AAAA".to_string()], + ); + + assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["size"], "1024x1024"); + assert_eq!(body["n"], 4); + assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印"); + assert!(body.get("image").is_none()); + } + + #[test] + fn provider_urls_normalize_root_and_v1_base_urls() { + let root_settings = VectorEngineImageSettings { + base_url: "https://vector.example".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + let v1_settings = VectorEngineImageSettings { + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + + assert_eq!( + vector_engine_images_generation_url(&root_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + vector_engine_images_generation_url(&v1_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + vector_engine_images_edit_url(&root_settings), + "https://vector.example/v1/images/edits" + ); + assert_eq!( + vector_engine_images_edit_url(&v1_settings), + "https://vector.example/v1/images/edits" + ); + } + + #[test] + fn data_url_and_base64_image_decoding_preserves_image_metadata() { + let data_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") + ); + + let reference = parse_reference_image_data_url(&data_url, 2) + .expect("data url should parse") + .expect("image data url should be accepted"); + assert_eq!(reference.file_name, "reference-2.png"); + assert_eq!(reference.mime_type, "image/png"); + assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest"); + + let image = decode_generated_image_base64(BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str()) + .expect("base64 image should decode"); + assert_eq!(image.extension, "png"); + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest"); + } + + #[test] + fn error_status_hints_and_audit_fields_are_structured() { + let audit = PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: "https://vector.example/v1/images/generations".to_string(), + operation: "图片生成失败".to_string(), + failure_stage: "upstream_status", + status_code: Some(504), + status_class: Some("5xx"), + timeout: true, + retryable: true, + error_message: "上游超时".to_string(), + error_source: Some("read timeout".to_string()), + raw_excerpt: Some("{\"error\":\"timeout\"}".to_string()), + latency_ms: Some(987), + prompt_chars: Some(64), + reference_image_count: Some(2), + image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + }; + + let request_error = PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求发送失败".to_string(), + endpoint: Some("https://vector.example/v1/images/generations".to_string()), + timeout: true, + connect: false, + request: true, + body: false, + status_code: None, + source: None, + audit: None, + }; + let invalid_config = PlatformImageError::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺少配置".to_string(), + }; + let invalid_request = PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求不合法".to_string(), + }; + let upstream_timeout = PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message: "upstream timeout".to_string(), + upstream_status: 502, + raw_excerpt: "deadline has elapsed".to_string(), + audit: Some(audit.clone()), + }; + + assert_eq!(invalid_config.status_hint(), PlatformImageStatusHint::ServiceUnavailable); + assert_eq!(invalid_request.status_hint(), PlatformImageStatusHint::BadRequest); + assert_eq!(request_error.status_hint(), PlatformImageStatusHint::GatewayTimeout); + assert_eq!(upstream_timeout.status_hint(), PlatformImageStatusHint::GatewayTimeout); + assert_eq!( + PlatformImageError::MissingImage { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺图".to_string(), + audit: Some(audit.clone()), + } + .status_hint(), + PlatformImageStatusHint::BadGateway + ); + + let audit_ref = upstream_timeout.audit().expect("audit should be preserved"); + assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER); + assert_eq!(audit_ref.endpoint, "https://vector.example/v1/images/generations"); + assert_eq!(audit_ref.status_code, Some(504)); + assert_eq!(audit_ref.status_class, Some("5xx")); + assert!(audit_ref.timeout); + assert!(audit_ref.retryable); + assert_eq!(audit_ref.reference_image_count, Some(2)); + assert_eq!(audit_ref.image_model, Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)); + assert!(invalid_config.audit().is_none()); + assert!(invalid_request.audit().is_none()); + } + + #[test] + fn extract_image_urls_and_b64_values_are_deduped() { + let payload = json!({ + "data": [ + {"image": "https://example.com/a.png"}, + {"url": "https://example.com/a.png"}, + {"image_url": "ftp://example.com/b.png"}, + {"url": "https://example.com/b.png"} + ], + "nested": { + "b64_json": ["YWJj", "ZGVm"] + } + }); + + assert_eq!( + extract_image_urls(&payload), + vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string() + ] + ); + assert_eq!( + extract_b64_images(&payload), + vec!["YWJj".to_string(), "ZGVm".to_string()] + ); + } +} diff --git a/server-rs/crates/platform-image/src/vector_engine/transport.rs b/server-rs/crates/platform-image/src/vector_engine/transport.rs new file mode 100644 index 00000000..2c771f52 --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/transport.rs @@ -0,0 +1,78 @@ +use std::{error::Error, time::Duration}; + +use super::{ + audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, + types::VectorEngineImageSettings, +}; + +pub fn build_vector_engine_image_http_client( + settings: &VectorEngineImageSettings, +) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) + .http1_only() + .build() + .map_err(|error| PlatformImageError::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), + }) +} + +pub(super) fn map_reqwest_error( + context: &str, + request_url: &str, + failure_stage: &'static str, + error: reqwest::Error, + latency_ms: u64, + prompt_chars: Option, + reference_image_count: Option, +) -> PlatformImageError { + let is_timeout = error.is_timeout(); + let is_connect = error.is_connect(); + let source = error.source().map(ToString::to_string); + let message = format!("{context}:{error}"); + let audit = build_failure_audit( + request_url, + context, + failure_stage, + error.status().map(|status| status.as_u16()), + None, + is_timeout, + is_connect, + message.as_str(), + source.clone(), + None, + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + failure_stage, + timeout = is_timeout, + connect = is_connect, + request = error.is_request(), + body = error.is_body(), + status = error.status().map(|status| status.as_u16()).unwrap_or_default(), + source = %source.clone().unwrap_or_default(), + message = %message, + elapsed_ms = latency_ms, + prompt_chars, + reference_image_count, + "VectorEngine 图片请求发送失败" + ); + + PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message, + endpoint: Some(request_url.to_string()), + timeout: is_timeout, + connect: is_connect, + request: error.is_request(), + body: error.is_body(), + status_code: error.status().map(|status| status.as_u16()), + source, + audit: Some(audit), + } +} diff --git a/server-rs/crates/platform-image/src/vector_engine/types.rs b/server-rs/crates/platform-image/src/vector_engine/types.rs new file mode 100644 index 00000000..5228b04b --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/types.rs @@ -0,0 +1,27 @@ +#[derive(Clone, Debug)] +pub struct VectorEngineImageSettings { + pub base_url: String, + pub api_key: String, + pub request_timeout_ms: u64, +} + +#[derive(Clone, Debug)] +pub struct GeneratedImages { + pub task_id: String, + pub actual_prompt: Option, + pub images: Vec, +} + +#[derive(Clone, Debug)] +pub struct DownloadedImage { + pub bytes: Vec, + pub mime_type: String, + pub extension: String, +} + +#[derive(Clone, Debug)] +pub struct ReferenceImage { + pub bytes: Vec, + pub mime_type: String, + pub file_name: String, +} diff --git a/server-rs/crates/platform-image/src/vector_engine/util.rs b/server-rs/crates/platform-image/src/vector_engine/util.rs new file mode 100644 index 00000000..ed6c487a --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/util.rs @@ -0,0 +1,89 @@ +use serde_json::Value; + +use super::{audit::PlatformImageFailureAudit, error::PlatformImageError}; + +pub(crate) fn is_timeout_message(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("timed out") + || lower.contains("timeout") + || lower.contains("operation timed out") + || lower.contains("deadline has elapsed") +} + +pub(crate) fn truncate_raw(raw_text: &str) -> String { + raw_text.chars().take(800).collect() +} + +pub(crate) fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +impl PlatformImageError { + pub(crate) fn with_audit(self, audit: PlatformImageFailureAudit) -> Self { + match self { + Self::Request { + provider, + message, + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + .. + } => Self::Request { + provider, + message, + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + audit: Some(audit), + }, + Self::Upstream { + provider, + message, + upstream_status, + raw_excerpt, + .. + } => Self::Upstream { + provider, + message, + upstream_status, + raw_excerpt, + audit: Some(audit), + }, + Self::ResponseParse { + provider, + message, + raw_excerpt, + .. + } => Self::ResponseParse { + provider, + message, + raw_excerpt, + audit: Some(audit), + }, + Self::MissingImage { + provider, message, .. + } => Self::MissingImage { + provider, + message, + audit: Some(audit), + }, + Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => self, + } + } +} + +pub(crate) struct ParsedJsonPayload { + pub(crate) payload: Value, +} diff --git a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs new file mode 100644 index 00000000..df530028 --- /dev/null +++ b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs @@ -0,0 +1,229 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; +use platform_image::DownloadedImage; +use platform_image::generated_asset_sheets::{ + GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, + GeneratedAssetSheetPromptInput, apply_generated_asset_sheet_green_screen_alpha, + build_generated_asset_sheet_prompt, crop_generated_asset_sheet_view_edge_matte, + prepare_generated_asset_sheet_put_request, slice_generated_asset_sheet, + slice_generated_asset_sheet_two_items_per_row, +}; +use platform_oss::LegacyAssetPrefix; + +fn encode_image(image: RgbaImage) -> Vec { + let mut encoded = std::io::Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("image should encode"); + encoded.into_inner() +} + +fn build_test_sheet(width: u32, height: u32) -> DownloadedImage { + let mut sheet = RgbaImage::new(width, height); + for row in 0..height / 100 { + for col in 0..width / 100 { + let row_u8 = row as u8; + let col_u8 = col as u8; + let color = Rgba([ + 32u8.saturating_add(row_u8.saturating_mul(40)), + 24u8.saturating_add(col_u8.saturating_mul(36)), + 210u8.saturating_sub(row_u8.saturating_mul(30)), + 255, + ]); + for y in row * 100..(row + 1) * 100 { + for x in col * 100..(col + 1) * 100 { + sheet.put_pixel(x, y, color); + } + } + } + } + + DownloadedImage { + bytes: encode_image(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + } +} + +#[test] +fn generated_asset_sheet_prompt_uses_default_rows_and_special_instruction() { + let item_names = vec!["草莓".to_string(), "苹果".to_string()]; + + let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { + subject_text: "水果题材的抓大鹅 2D 物品素材", + item_names: &item_names, + grid_size: 5, + item_name_prompt_template: None, + special_prompt: None, + }) + .expect("prompt should build"); + + assert!(prompt.contains("5行*5列")); + assert!(prompt.contains("第1行:草莓 的 5 个不同视图")); + assert!(prompt.contains("第2行:苹果 的 5 个不同视图")); + assert!(prompt.contains("每个物品生成 5 个不同视图")); +} + +#[test] +fn generated_asset_sheet_prompt_allows_custom_row_template_and_special_prompt() { + let item_names = vec!["草莓".to_string()]; + + let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { + subject_text: "水果题材的抓大鹅 2D 物品素材", + item_names: &item_names, + grid_size: 5, + item_name_prompt_template: Some("第{row_index}行是 {item_name},共 {view_count} 个视图"), + special_prompt: Some("每个物品要生成五个不同视图:正面、左前、右前、俯视、背面。"), + }) + .expect("prompt should build"); + + assert!(prompt.contains("第1行是 草莓,共 5 个视图")); + assert!(prompt.contains("每个物品要生成五个不同视图")); +} + +#[test] +fn generated_asset_sheet_prompt_rejects_zero_grid_size() { + let item_names = vec!["草莓".to_string()]; + + let error = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { + subject_text: "水果题材的抓大鹅 2D 物品素材", + item_names: &item_names, + grid_size: 0, + item_name_prompt_template: None, + special_prompt: None, + }) + .expect_err("grid size 0 should be rejected"); + + assert_eq!(error.provider(), "generated-asset-sheets"); +} + +#[test] +fn generated_asset_sheet_slices_by_requested_grid_size() { + let item_names = vec!["樱桃".to_string(), "苹果".to_string()]; + let image = build_test_sheet(500, 500); + + let slices = slice_generated_asset_sheet(&image, &item_names, 5).expect("sheet should slice"); + + assert_eq!(slices.len(), 2); + assert_eq!(slices[0].len(), 5); + assert_eq!(slices[1].len(), 5); +} + +#[test] +fn generated_asset_sheet_two_items_per_row_slices_match3d_layout() { + let item_names = vec![ + "苹果".to_string(), + "香蕉".to_string(), + "葡萄".to_string(), + "草莓".to_string(), + ]; + let image = build_test_sheet(1000, 1000); + + let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5) + .expect("sheet should slice"); + + assert_eq!(slices.len(), 4); + assert!(slices.iter().all(|views| views.len() == 5)); +} + +#[test] +fn generated_asset_sheet_green_screen_alpha_removes_green_background() { + let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 255, 0, 255])); + for y in 6..14 { + for x in 6..14 { + sheet.put_pixel(x, y, Rgba([220, 40, 40, 255])); + } + } + + let cleaned = + apply_generated_asset_sheet_green_screen_alpha(DynamicImage::ImageRgba8(sheet)).to_rgba8(); + + assert_eq!(cleaned.get_pixel(0, 0).0[3], 0); + assert_eq!(cleaned.get_pixel(10, 10).0[3], 255); +} + +#[test] +fn generated_asset_sheet_view_edge_matte_trims_transparent_border() { + let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0])); + for y in 4..16 { + for x in 4..16 { + sheet.put_pixel(x, y, Rgba([220, 40, 40, 255])); + } + } + + let cropped = + crop_generated_asset_sheet_view_edge_matte(DynamicImage::ImageRgba8(sheet)).to_rgba8(); + + assert_eq!(cropped.width(), 12); + assert_eq!(cropped.height(), 12); + assert_eq!(cropped.get_pixel(0, 0).0[3], 255); +} + +#[test] +fn generated_asset_sheet_prepare_put_request_packs_prompt_metadata() { + let request = prepare_generated_asset_sheet_put_request(GeneratedAssetSheetPersistInput { + prefix: LegacyAssetPrefix::Match3DAssets, + owner_user_id: "user-1".to_string(), + session_id: "session-1".to_string(), + profile_id: "profile-1".to_string(), + path_segments: vec!["items".to_string(), "view".to_string()], + file_name: "view-01.png".to_string(), + content_type: "image/png".to_string(), + bytes: b"sheet-bytes".to_vec(), + asset_kind: "match3d_item_image_view".to_string(), + source_job_id: Some("task-1".to_string()), + generated_at_micros: 123, + grid_size: 5, + row_index: 1, + view_index: 2, + prompt: GeneratedAssetSheetPersistPrompt { + sheet_prompt: Some("sheet prompt".to_string()), + item_name_prompt: Some("item prompt".to_string()), + special_prompt: Some("special prompt".to_string()), + }, + }) + .expect("request should prepare"); + + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-prompt-encoding"), + Some(&"utf8-base64".to_string()) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-grid-size"), + Some(&"5".to_string()) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-row-index"), + Some(&"1".to_string()) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-view-index"), + Some(&"2".to_string()) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-prompt-b64"), + Some(&BASE64_STANDARD.encode("sheet prompt")) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-item-name-prompt-b64"), + Some(&BASE64_STANDARD.encode("item prompt")) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-special-prompt-b64"), + Some(&BASE64_STANDARD.encode("special prompt")) + ); +} diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs new file mode 100644 index 00000000..e9bfb1e0 --- /dev/null +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -0,0 +1,32 @@ +use platform_image::vector_engine::{ + GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, + build_vector_engine_image_request_body, vector_engine_images_edit_url, + vector_engine_images_generation_url, +}; + +#[test] +fn vector_engine_module_exposes_provider_protocol_helpers() { + let settings = VectorEngineImageSettings { + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + + let body = + build_vector_engine_image_request_body("雾海神殿", Some("文字,水印"), "16:9", 9, &[]); + + assert_eq!(GPT_IMAGE_2_MODEL, "gpt-image-2"); + assert_eq!(VECTOR_ENGINE_PROVIDER, "vector-engine"); + assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["size"], "1536x1024"); + assert_eq!(body["n"], 4); + assert_eq!(body["prompt"], "雾海神殿\n避免:文字,水印"); + assert_eq!( + vector_engine_images_generation_url(&settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + vector_engine_images_edit_url(&settings), + "https://vector.example/v1/images/edits" + ); +} diff --git a/server-rs/crates/shared-contracts/src/wooden_fish.rs b/server-rs/crates/shared-contracts/src/wooden_fish.rs index 18788a9e..c7116993 100644 --- a/server-rs/crates/shared-contracts/src/wooden_fish.rs +++ b/server-rs/crates/shared-contracts/src/wooden_fish.rs @@ -381,8 +381,7 @@ mod tests { }), background_asset: Some(WoodenFishImageAsset { asset_id: "background-1".to_string(), - image_src: "/generated-wooden-fish-assets/profile/background/image.png" - .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(), diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 2b35ba32..28a5ed25 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -386,16 +386,18 @@ impl SpacetimeClient { .items .into_iter() .find(|item| { - normalize_jump_hop_public_work_code(item.public_work_code.as_str()) == requested_code + normalize_jump_hop_public_work_code(item.public_work_code.as_str()) + == requested_code }) - .ok_or_else(|| SpacetimeClientError::Procedure("jump_hop public work 不存在".to_string()))?; + .ok_or_else(|| { + SpacetimeClientError::Procedure("jump_hop public work 不存在".to_string()) + })?; self.get_jump_hop_work_profile(card.profile_id, String::new()) .await } } - fn validate_jump_hop_runtime_ready( work: &JumpHopWorkProfileResponse, ) -> Result<(), SpacetimeClientError> { @@ -689,12 +691,7 @@ fn build_compile_input( } else { draft.tile_assets.clone() }; - let cover_composite = resolve_cover_composite( - draft, - profile_id, - refresh, - now_micros, - ); + let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros); draft.cover_composite = cover_composite.clone(); draft.generation_status = JumpHopGenerationStatus::Ready; diff --git a/server-rs/crates/spacetime-module/src/bark_battle.rs b/server-rs/crates/spacetime-module/src/bark_battle.rs index da981776..40d87fff 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle.rs @@ -1115,6 +1115,9 @@ mod tests { published_at_micros: 1_713_686_401_234_000, }; - assert_eq!(row.onomatopoeia, vec!["轰!".to_string(), "炸场!".to_string()]); + assert_eq!( + row.onomatopoeia, + vec!["轰!".to_string(), "炸场!".to_string()] + ); } } From fbda61415670a5172b3b243b3463946e2a8153ea Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 26 May 2026 14:27:18 +0800 Subject: [PATCH 2/5] feat: surface platform errors in copyable dialogs --- .hermes/shared-memory/decision-log.md | 16 + .hermes/shared-memory/pitfalls.md | 24 + ...玩法创作】平台入口与玩法链路-2026-05-15.md | 6 +- ...项目基线】当前产品与工程约束-2026-05-15.md | 11 +- src/components/CustomWorldGenerationView.tsx | 16 +- .../CustomWorldCreationHub.tsx | 12 +- .../CustomWorldCreationStartCard.tsx | 9 +- .../PlatformEntryCreationTypeModal.test.tsx | 1 - .../PlatformEntryCreationTypeModal.tsx | 7 - .../PlatformEntryFlowShellImpl.tsx | 415 ++++++++++++++++-- .../PlatformErrorDialog.test.tsx | 60 +++ .../platform-entry/PlatformErrorDialog.tsx | 120 +++++ .../platform-entry/PlatformWorkDetailView.tsx | 7 +- .../puzzle-result/PuzzleResultView.tsx | 7 +- .../RpgEntryHomeView.recharge.test.tsx | 56 +-- src/components/rpg-entry/RpgEntryHomeView.tsx | 139 +++--- 16 files changed, 715 insertions(+), 191 deletions(-) create mode 100644 src/components/platform-entry/PlatformErrorDialog.test.tsx create mode 100644 src/components/platform-entry/PlatformErrorDialog.tsx diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index c4a20efe..070fd6ca 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-05-26 平台跨流程错误统一用可复制来源弹窗展示 + +- 背景:拼图等生成链路可能同时存在多个草稿或游玩实例,页面内裸错误 banner 容易让用户误以为当前正在看的拼图失败,也不方便复制完整错误给开发排查。 +- 决策:平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`;弹窗必须带错误来源,例如某个草稿、生成会话、作品详情或游玩实例,并提供复制按钮复制来源与错误内容。页面内旧的裸错误 banner、创作入口 modal 错误、生成页错误徽标等不再重复展示;表单校验和发布确认弹窗里的局部业务错误仍可保留在原弹窗内。 +- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`src/components/CustomWorldGenerationView.tsx`、`src/components/custom-world-home/CustomWorldCreationHub.tsx`、`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`src/components/platform-entry/PlatformWorkDetailView.tsx`、`src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`。 +- 验证方式:`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx`、`npm run typecheck`、`npm run check:encoding` 通过;手测时异步失败应弹出包含“错误来源”和“错误内容”的弹窗,复制按钮应复制完整诊断文本。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-26 “我的”页任务卡读后端任务摘要并移除常驻填邀请码入口 + +- 背景:移动端“我的”页每日任务卡曾硬编码 `0 / 1`,任务领取完成后只刷新弹窗内任务中心,卡片本身不更新;页面底部还保留旧的“填邀请码”次级按钮,和当前五项常用功能宫格口径重复。 +- 决策:`RpgEntryHomeView` 的每日任务卡以 `/api/profile/tasks` 返回的任务中心为事实源,展示当前可操作任务的奖励、进度和状态;领取成功后同步使用 claim 响应里的 `center` 刷新卡片。移动端“我的”页不再渲染常驻“填邀请码”次级入口,邀请码填写仅保留邀请链接 query 自动打开弹窗和其它明确引导。 +- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应断言任务卡显示 `1 / 1`、领取后显示已完成,且新用户账号也没有 `次级入口` / `填邀请码` 常驻按钮;`npm run typecheck`、`npm run check:encoding` 通过。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 + ## 2026-05-25 平台首页推荐按桌面与移动断点分流 - 背景:平台首页的推荐页在桌面与移动端之间原先共用同一套推荐运行态逻辑,容易让桌面和移动两套内容同时启动,也让首页的推荐卡与桌面发现壳互相抢状态。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index c9f1c0d1..f086ebc2 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -15,6 +15,30 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 平台异步错误必须带来源弹窗,不要只显示裸错误 + +- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。 +- 原因:不同入口、生成页、结果页、作品详情和运行态各自渲染局部错误,没有统一携带草稿、生成会话、作品或游玩来源。 +- 处理:跨流程错误统一由 `PlatformEntryFlowShellImpl` 汇总为 `PlatformErrorDialog`,来源使用玩法、草稿 / session / work / run 标识组成;弹窗提供复制按钮。关闭弹窗时只清理可安全清理的错误状态;恢复类错误用 dismiss key 防止反复弹出但不擅自改底层状态。 +- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## “我的”页每日任务卡不要硬编码进度 + +- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。 +- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗。 +- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`,领取后显示已完成。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 + +## “我的”页不要恢复旧的填邀请码次级按钮 + +- 现象:移动端“我的”页在五项常用功能和设置入口下方又出现一个“填邀请码”按钮,看起来像旧入口残留。 +- 原因:邀请码流程迁移后仍按新用户窗口保留 `canShowReferralRedeemShortcut` 次级入口;但当前页面口径已经固定为五项常用功能宫格,邀请码填写应由邀请链接 query 或明确引导打开弹窗。 +- 处理:移除常驻 `次级入口` / `填邀请码` 渲染,不删除 `ProfileReferralModal` 的 `redeem` 面板,也不破坏 `?inviteCode=` / `?invite_code=` 自动打开填写弹窗。 +- 验证:新用户账号打开“我的”页时没有 `次级入口` 和 `填邀请码` 按钮;带 `?inviteCode=spring-2026` 的登录用户仍自动打开邀请码弹窗并预填 `SPRING2026`。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`.hermes/skills/genarrative-profile-invite-flow/SKILL.md`。 + ## 创作卡片点击要直达已有入口表单,别再保留空白入口页 - 现象:创作 Tab 模板卡点击后如果仍然停留在创作大厅,或者先进入“X 创作入口”这种空白页,就会让用户多走一层,还可能被错误的 stage 白名单拉回平台。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 0e593209..600e0327 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -12,6 +12,8 @@ 创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 +平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。 + `PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 `platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recent` / `最近创作`,避免旧数据、局部 mock 或异常返回把创作入口初始化直接打崩。 @@ -44,7 +46,7 @@ 6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。 7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 -发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、次级入口带和法律信息组织,但字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 +发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 ## RPG / 自定义世界 @@ -72,7 +74,7 @@ RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization,持久写入 `story_continue_adventure`、`deferredOptions`、`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset`、`currentSceneActState`、`currentEncounter` 和 `runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。 -RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列表为真相,恢复动作继续走对应恢复接口,但移动端“我的”页已经不再提供独立的 `次级入口 > 存档` 和设置入口存档按钮;“玩过”弹窗可以继续合并展示可继续存档,个人中心只保留设置、扫码、常用功能和条件性次级入口。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 +RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列表为真相,恢复动作继续走对应恢复接口,但移动端“我的”页已经不再提供独立的 `次级入口 > 存档` 和设置入口存档按钮;“玩过”弹窗可以继续合并展示可继续存档,个人中心只保留设置、扫码和五项常用功能。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档或填邀请码挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 ## 拼图 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 0007216b..2544e47d 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -93,11 +93,12 @@ server-rs + Axum + SpacetimeDB 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 -10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、可选次级入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,填邀请码仅在新用户可填写窗口内展示为次级入口。 -11. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 -12. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能、可选次级入口和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 -13. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 -14. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 +10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。 +11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数、进度和领取 / 去完成 / 已完成状态;任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。 +12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 +13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 +14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 +15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 ## 文案与编码 diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index 9a00c842..55efe604 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -13,7 +13,7 @@ interface CustomWorldGenerationViewProps { anchorEntries?: CustomWorldStructuredAnchorEntry[]; progress: CustomWorldGenerationProgress | null; isGenerating: boolean; - error: string | null; + error?: string | null; onBack: () => void; onEditSetting: () => void; onRetry: () => void; @@ -110,7 +110,6 @@ export function CustomWorldGenerationView({ anchorEntries = [], progress, isGenerating, - error, onBack, onEditSetting, onRetry, @@ -123,7 +122,6 @@ export function CustomWorldGenerationView({ settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。', progressTitle = '生成进度', activeBadgeLabel = '世界建设中', - pausedBadgeLabel = '生成已暂停', idleBadgeLabel = '等待操作', structuredEmptyText = '正在整理当前设定结构,请稍后。', hideBatchModule = false, @@ -169,11 +167,7 @@ export function CustomWorldGenerationView({ {backLabel}
- {isGenerating - ? activeBadgeLabel - : error - ? pausedBadgeLabel - : idleBadgeLabel} + {isGenerating ? activeBadgeLabel : idleBadgeLabel}
@@ -195,12 +189,6 @@ export function CustomWorldGenerationView({ /> - {error ? ( -
- {error} -
- ) : null} -
{!isGenerating ? ( <> diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index ccd20cd5..12520cf8 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -1,12 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; -import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; @@ -43,7 +43,6 @@ type CustomWorldCreationHubProps = { loading: boolean; error: string | null; onRetry: () => void; - createError?: string | null; createBusy?: boolean; entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; @@ -154,7 +153,6 @@ export function CustomWorldCreationHub({ loading, error, onRetry, - createError = null, createBusy = false, entryConfig, creationTypes, @@ -360,7 +358,6 @@ export function CustomWorldCreationHub({ {showStartCard ? ( -
{error}
+
diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx index c5021674..6647e42c 100644 --- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx +++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx @@ -1,5 +1,5 @@ import { Coins, Trophy } from 'lucide-react'; -import { useMemo, useState, type UIEvent } from 'react'; +import { type UIEvent,useMemo, useState } from 'react'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import { @@ -10,7 +10,6 @@ import { type CustomWorldCreationStartCardProps = { busy?: boolean; - error?: string | null; entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; onCreateType: (type: PlatformCreationTypeId) => void; @@ -25,7 +24,6 @@ function shouldShowCreationBadge(badge: string) { export function CustomWorldCreationStartCard({ busy = false, - error = null, entryConfig, creationTypes, onCreateType, @@ -233,11 +231,6 @@ export function CustomWorldCreationStartCard({ })}
- {error ? ( -
- {error} -
- ) : null}
); diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx index 583c19ad..fdfe2575 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx @@ -51,7 +51,6 @@ test('dispatches wooden fish creation type selection', () => { {}} diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 8d6698aa..21b297b9 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -10,7 +10,6 @@ import { export interface PlatformEntryCreationTypeModalProps { isOpen: boolean; isBusy: boolean; - error: string | null; entryConfig: CreationEntryConfig; creationTypes: readonly PlatformCreationTypeCard[]; onClose: () => void; @@ -94,7 +93,6 @@ function CreationTypeCard(props: { export function PlatformEntryCreationTypeModal({ isOpen, isBusy, - error, entryConfig, creationTypes, onClose, @@ -172,11 +170,6 @@ export function PlatformEntryCreationTypeModal({ ))} - {error ? ( -
- {error} -
- ) : null} ); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index c6359f6b..a74239f8 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -38,6 +38,7 @@ import type { BabyObjectMatchDraft, CreateBabyObjectMatchDraftRequest, } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { CreateMatch3DSessionRequest, ExecuteMatch3DActionRequest, @@ -154,13 +155,6 @@ import { type CreationEntryConfig, fetchCreationEntryConfig, } from '../../services/creationEntryConfigService'; -import { - cancelCreativeAgentSession, - confirmCreativePuzzleTemplate, - createCreativeAgentSession, - streamCreativeAgentMessage, - streamCreativeDraftEdit, -} from '../../services/creative-agent'; import { clearCreationUrlState, type CreationUrlState, @@ -169,11 +163,12 @@ import { writeCreationUrlState, } from '../../services/creationUrlState'; import { - clearPuzzleRuntimeUrlState, - readPuzzleRuntimeUrlState, - writePuzzleRuntimeUrlState, - type PuzzleRuntimeUrlState, -} from '../../services/puzzleRuntimeUrlState'; + cancelCreativeAgentSession, + confirmCreativePuzzleTemplate, + createCreativeAgentSession, + streamCreativeAgentMessage, + streamCreativeDraftEdit, +} from '../../services/creative-agent'; import { readCustomWorldAgentUiState, shouldRestoreCustomWorldAgentUiState, @@ -196,7 +191,6 @@ import { JumpHopWorkProfileResponse, JumpHopWorkspaceCreateRequest, } from '../../services/jump-hop/jumpHopClient'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import { match3dCreationClient } from '../../services/match3d-creation'; import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { @@ -287,6 +281,12 @@ import { listPuzzleWorks, updatePuzzleWork, } from '../../services/puzzle-works'; +import { + clearPuzzleRuntimeUrlState, + type PuzzleRuntimeUrlState, + readPuzzleRuntimeUrlState, + writePuzzleRuntimeUrlState, +} from '../../services/puzzleRuntimeUrlState'; import { deleteRpgCreationAgentSession } from '../../services/rpg-creation'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import { @@ -375,6 +375,7 @@ import { mapVisualNovelWorkToPlatformGalleryCard, mapWoodenFishWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, + resolvePlatformPublicWorkCode, } from '../rpg-entry/rpgEntryWorldPresentation'; import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; @@ -414,6 +415,7 @@ import { PlatformEntryHomeView, type PlatformHomeTab, } from './PlatformEntryHomeView'; +import { usePlatformDesktopLayout } from './platformEntryResponsive'; import { buildCreationHubFallbackItems, resolveRpgCreationErrorMessage, @@ -423,11 +425,14 @@ import type { SelectionStage, } from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; +import { + PlatformErrorDialog, + type PlatformErrorDialogPayload, +} from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; -import { usePlatformDesktopLayout } from './platformEntryResponsive'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; import { usePlatformEntryNavigation } from './usePlatformEntryNavigation'; @@ -2012,6 +2017,22 @@ function createPendingDraftShelfState( }; } +function normalizePlatformErrorMessage(message: string | null | undefined) { + const normalized = message?.trim(); + return normalized ? normalized : null; +} + +function formatPlatformErrorSource(label: string, id?: string | null) { + const normalizedId = id?.trim(); + return normalizedId ? `${label} ${normalizedId}` : label; +} + +function buildPlatformErrorDialogDismissKey( + error: (PlatformErrorDialogPayload & { key: string }) | null, +) { + return error ? `${error.key}:${error.source}:${error.message}` : null; +} + function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, metadata?: MiniGameDraftGenerationState['metadata'], @@ -5767,6 +5788,336 @@ export function PlatformEntryFlowShellImpl({ isMiniGameDraftGenerating( activePuzzleBackgroundCompileTask?.generationState ?? null, ); + const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] = + useState(null); + const currentPlatformErrorDialog = useMemo< + (PlatformErrorDialogPayload & { key: string }) | null + >(() => { + const candidates: Array<{ + key: string; + source: string; + message: string | null | undefined; + }> = [ + { + key: 'creation-entry-config', + source: '创作入口配置', + message: creationEntryConfigError, + }, + { + key: 'platform-bootstrap', + source: '平台首页', + message: platformBootstrap.platformError, + }, + { + key: 'rpg-creation-type', + source: '创作入口', + message: sessionController.creationTypeError, + }, + { + key: 'rpg-restore', + source: '创作作品架', + message: sessionController.agentWorkspaceRestoreError, + }, + { + key: 'rpg-result', + source: formatPlatformErrorSource( + 'RPG 草稿', + sessionController.agentSession?.sessionId ?? + sessionController.generatedCustomWorldProfile?.id, + ), + message: resultViewError, + }, + { + key: 'public-work-detail', + source: formatPlatformErrorSource( + '作品详情', + selectedPublicWorkDetail + ? resolvePlatformPublicWorkCode(selectedPublicWorkDetail) + : selectedDetailEntry?.profileId, + ), + message: publicWorkDetailError ?? detailNavigation.detailError, + }, + { + key: 'big-fish', + source: formatPlatformErrorSource( + selectionStage === 'big-fish-runtime' ? '大鱼吃小鱼游玩' : '大鱼草稿', + bigFishRun?.runId ?? bigFishSession?.sessionId, + ), + message: bigFishError, + }, + { + key: 'match3d', + source: formatPlatformErrorSource( + selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅草稿', + match3dRun?.runId ?? + match3dGenerationViewSession?.sessionId ?? + match3dSession?.sessionId, + ), + message: match3dGenerationViewError ?? match3dError, + }, + { + key: 'square-hole', + source: formatPlatformErrorSource( + selectionStage === 'square-hole-runtime' + ? '方洞挑战游玩' + : '方洞挑战草稿', + squareHoleRun?.runId ?? squareHoleSession?.sessionId, + ), + message: squareHoleError, + }, + { + key: 'jump-hop', + source: formatPlatformErrorSource( + selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳草稿', + jumpHopRun?.runId ?? jumpHopSession?.sessionId, + ), + message: jumpHopError, + }, + { + key: 'wooden-fish', + source: formatPlatformErrorSource( + selectionStage === 'wooden-fish-runtime' + ? '敲木鱼游玩' + : '敲木鱼草稿', + woodenFishRun?.runId ?? woodenFishSession?.sessionId, + ), + message: woodenFishError, + }, + { + key: 'puzzle', + source: formatPlatformErrorSource( + selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图草稿', + puzzleRun?.runId ?? + puzzleGenerationViewSession?.sessionId ?? + puzzleSession?.sessionId, + ), + message: puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError, + }, + { + key: 'puzzle-onboarding', + source: '拼图首次创作', + message: puzzleOnboardingError, + }, + { + key: 'puzzle-shelf', + source: '拼图作品架', + message: puzzleShelfError, + }, + { + key: 'visual-novel', + source: formatPlatformErrorSource( + selectionStage === 'visual-novel-runtime' + ? '视觉小说游玩' + : '视觉小说草稿', + visualNovelRun?.runId ?? visualNovelSession?.sessionId, + ), + message: visualNovelError, + }, + { + key: 'baby-object-match', + source: formatPlatformErrorSource( + selectionStage === 'baby-object-match-runtime' + ? '宝贝识物游玩' + : '宝贝识物草稿', + babyObjectMatchDraft?.profileId, + ), + message: babyObjectMatchError, + }, + { + key: 'bark-battle', + source: formatPlatformErrorSource( + selectionStage === 'bark-battle-runtime' + ? '汪汪声浪游玩' + : '汪汪声浪草稿', + barkBattlePublishedConfig?.workId ?? barkBattleDraftConfig?.workId, + ), + message: barkBattleError, + }, + { + key: 'creative-agent', + source: formatPlatformErrorSource( + '智能创作 Agent', + creativeAgentSession?.sessionId, + ), + message: creativeAgentError, + }, + { + key: 'rpg-generation', + source: formatPlatformErrorSource( + 'RPG 草稿生成', + sessionController.agentSession?.sessionId, + ), + message: sessionController.activeGenerationError, + }, + ]; + + for (const candidate of candidates) { + const message = normalizePlatformErrorMessage(candidate.message); + if (message) { + return { + key: candidate.key, + source: candidate.source, + message, + }; + } + } + + return null; + }, [ + babyObjectMatchDraft?.profileId, + babyObjectMatchError, + barkBattleDraftConfig?.workId, + barkBattleError, + barkBattlePublishedConfig?.workId, + bigFishError, + bigFishRun?.runId, + bigFishSession?.sessionId, + creationEntryConfigError, + creativeAgentError, + creativeAgentSession?.sessionId, + detailNavigation.detailError, + jumpHopError, + jumpHopRun?.runId, + jumpHopSession?.sessionId, + match3dError, + match3dGenerationViewError, + match3dGenerationViewSession?.sessionId, + match3dRun?.runId, + match3dSession?.sessionId, + platformBootstrap.platformError, + publicWorkDetailError, + puzzleCreationError, + puzzleError, + puzzleGenerationViewError, + puzzleGenerationViewSession?.sessionId, + puzzleOnboardingError, + puzzleRun?.runId, + puzzleSession?.sessionId, + puzzleShelfError, + resultViewError, + selectedDetailEntry?.profileId, + selectedPublicWorkDetail, + selectionStage, + sessionController.activeGenerationError, + sessionController.agentSession?.sessionId, + sessionController.agentWorkspaceRestoreError, + sessionController.creationTypeError, + sessionController.generatedCustomWorldProfile?.id, + squareHoleError, + squareHoleRun?.runId, + squareHoleSession?.sessionId, + visualNovelError, + visualNovelRun?.runId, + visualNovelSession?.sessionId, + woodenFishError, + woodenFishRun?.runId, + woodenFishSession?.sessionId, + ]); + const activePlatformErrorDialogDismissKey = + buildPlatformErrorDialogDismissKey(currentPlatformErrorDialog); + const activePlatformErrorDialog = + activePlatformErrorDialogDismissKey && + activePlatformErrorDialogDismissKey === dismissedPlatformErrorDialogKey + ? null + : currentPlatformErrorDialog; + const closePlatformErrorDialog = useCallback(() => { + if (!currentPlatformErrorDialog) { + return; + } + + const dismissKey = buildPlatformErrorDialogDismissKey( + currentPlatformErrorDialog, + ); + if (dismissKey) { + setDismissedPlatformErrorDialogKey(dismissKey); + } + + if (currentPlatformErrorDialog.key === 'creation-entry-config') { + setCreationEntryConfigError(null); + return; + } + if (currentPlatformErrorDialog.key === 'platform-bootstrap') { + platformBootstrap.setPlatformError(null); + return; + } + if (currentPlatformErrorDialog.key === 'rpg-creation-type') { + sessionController.setCreationTypeError(null); + return; + } + if (currentPlatformErrorDialog.key === 'rpg-restore') { + return; + } + if ( + currentPlatformErrorDialog.key === 'rpg-result' || + currentPlatformErrorDialog.key === 'rpg-generation' + ) { + autosaveCoordinator.setCustomWorldAutoSaveError(null); + sessionController.setCustomWorldError(null); + return; + } + if (currentPlatformErrorDialog.key === 'public-work-detail') { + setPublicWorkDetailError(null); + detailNavigation.setDetailError(null); + return; + } + if (currentPlatformErrorDialog.key === 'big-fish') { + setBigFishError(null); + return; + } + if (currentPlatformErrorDialog.key === 'match3d') { + setMatch3DError(null); + return; + } + if (currentPlatformErrorDialog.key === 'square-hole') { + setSquareHoleError(null); + return; + } + if (currentPlatformErrorDialog.key === 'jump-hop') { + setJumpHopError(null); + return; + } + if (currentPlatformErrorDialog.key === 'wooden-fish') { + setWoodenFishError(null); + return; + } + if ( + currentPlatformErrorDialog.key === 'puzzle' || + currentPlatformErrorDialog.key === 'puzzle-onboarding' || + currentPlatformErrorDialog.key === 'puzzle-shelf' + ) { + setPuzzleCreationError(null); + setPuzzleOnboardingError(null); + setPuzzleShelfError(null); + setPuzzleError(null); + return; + } + if (currentPlatformErrorDialog.key === 'visual-novel') { + setVisualNovelError(null); + return; + } + if (currentPlatformErrorDialog.key === 'baby-object-match') { + setBabyObjectMatchError(null); + return; + } + if (currentPlatformErrorDialog.key === 'bark-battle') { + setBarkBattleError(null); + return; + } + if (currentPlatformErrorDialog.key === 'creative-agent') { + setCreativeAgentError(null); + } + }, [ + autosaveCoordinator, + currentPlatformErrorDialog, + detailNavigation, + platformBootstrap, + sessionController, + setBigFishError, + setMatch3DError, + setPuzzleError, + setSquareHoleError, + setVisualNovelError, + ]); const shouldPollPuzzleGenerationSession = selectionStage === 'puzzle-generating' && activePuzzleGenerationSessionId != null && @@ -14098,19 +14449,6 @@ export function PlatformEntryFlowShellImpl({ void refreshBabyObjectMatchShelf(); void refreshBarkBattleShelf(); }} - createError={ - creationEntryConfigError ?? - sessionController.creationTypeError ?? - bigFishError ?? - match3dError ?? - (isSquareHoleCreationVisible ? squareHoleError : null) ?? - woodenFishError ?? - puzzleCreationError ?? - puzzleError ?? - (isVisualNovelCreationOpen ? visualNovelError : null) ?? - babyObjectMatchError ?? - barkBattleError - } createBusy={ !creationEntryConfig || sessionController.isCreatingAgentSession || @@ -15762,7 +16100,6 @@ export function PlatformEntryFlowShellImpl({ settingDescription={null} progressTitle="拼图草稿生成进度" activeBadgeLabel="草稿生成中" - pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" hideBatchModule /> @@ -16420,7 +16757,7 @@ export function PlatformEntryFlowShellImpl({ {creationEntryConfig ? ( - { @@ -16542,6 +16865,12 @@ export function PlatformEntryFlowShellImpl({ payload={publishSharePayload} onClose={() => setPublishSharePayload(null)} /> + ({ + copyTextToClipboard: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('PlatformErrorDialog', () => { + test('shows source, message, and copies the full error report', async () => { + vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true); + + render( + {}} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '发生错误' }); + expect(within(dialog).getByText('拼图草稿 puzzle-session-123')).toBeTruthy(); + expect(within(dialog).getByText('图片生成失败,请稍后再试。')).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '复制报错' })); + + expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( + ['来源:拼图草稿 puzzle-session-123', '错误:图片生成失败,请稍后再试。'].join( + '\n', + ), + ); + await waitFor(() => { + expect( + within(dialog).getByRole('button', { name: '已复制' }), + ).toBeTruthy(); + }); + }); + + test('does not render when there is no active error', () => { + render( {}} />); + + expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull(); + }); +}); diff --git a/src/components/platform-entry/PlatformErrorDialog.tsx b/src/components/platform-entry/PlatformErrorDialog.tsx new file mode 100644 index 00000000..794a6a5c --- /dev/null +++ b/src/components/platform-entry/PlatformErrorDialog.tsx @@ -0,0 +1,120 @@ +import { Check, Copy } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { copyTextToClipboard } from '../../services/clipboard'; +import { UnifiedModal } from '../common/UnifiedModal'; + +export type PlatformErrorDialogPayload = { + source: string; + message: string; +}; + +type PlatformErrorDialogProps = { + error: PlatformErrorDialogPayload | null; + onClose: () => void; + overlayClassName?: string; + panelClassName?: string; +}; + +function buildPlatformErrorReport(error: PlatformErrorDialogPayload) { + return [`来源:${error.source}`, `错误:${error.message}`].join('\n'); +} + +export function PlatformErrorDialog({ + error, + onClose, + overlayClassName = 'platform-theme platform-theme--light !items-center', + panelClassName = 'platform-remap-surface rounded-[1.5rem]', +}: PlatformErrorDialogProps) { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const resetTimerRef = useRef(null); + const reportText = useMemo( + () => (error ? buildPlatformErrorReport(error) : ''), + [error], + ); + + useEffect( + () => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, + [], + ); + + useEffect(() => { + setCopyState('idle'); + }, [error?.source, error?.message]); + + const copyError = () => { + if (!reportText) { + return; + } + + void copyTextToClipboard(reportText).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + resetTimerRef.current = null; + setCopyState('idle'); + }, 1400); + }); + }; + + return ( + + {copyState === 'copied' ? ( + + ) : ( + + )} + {copyState === 'copied' + ? '已复制' + : copyState === 'failed' + ? '复制失败' + : '复制报错'} + + } + > + {error ? ( + <> +
+
+ 来源 +
+
+ {error.source} +
+
+
+
+ 错误 +
+
+ {error.message} +
+
+ + ) : null} +
+ ); +} diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index 7477d978..65d3c404 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -24,7 +24,6 @@ import { formatPlatformWorldTime, isBarkBattleGalleryEntry, isEdutainmentGalleryEntry, - isJumpHopGalleryEntry, type PlatformPublicGalleryCard, resolvePlatformPublicWorkCode, resolvePlatformWorldCoverSlides, @@ -36,7 +35,7 @@ export interface PlatformWorkDetailViewProps { authorAvatarUrl?: string | null; authorDisplayName?: string | null; isBusy: boolean; - error: string | null; + error?: string | null; visibleCoverCount?: number; onBack: () => void; onLike: () => void; @@ -89,7 +88,6 @@ export function PlatformWorkDetailView({ authorAvatarUrl, authorDisplayName, isBusy, - error, visibleCoverCount = 1, onBack, onLike, @@ -432,9 +430,6 @@ export function PlatformWorkDetailView({ {shareState === 'copied' ? '分享内容已复制' : '分享失败'} ) : null} - {error ? ( -
{error}
- ) : null} diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 4b280ad5..093b529a 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -1819,12 +1819,7 @@ export function PuzzleResultView({ ) : null} - {error ? ( -
- {error} -
- ) : null} - {!error && autoSaveError ? ( + {autoSaveError ? (
{autoSaveError}
diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index f27b62c7..f336c6d6 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1826,11 +1826,18 @@ test('non-wechat profile opens reward code from recharge-shaped entry', async () expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); }); -test('profile daily task shortcut opens task center and claims reward', async () => { +test('profile daily task shortcut reflects task progress and claim updates', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); renderProfileView(onRechargeSuccess); + + const dailyTask = screen.getByRole('button', { name: /每日任务/u }); + await waitFor(() => { + expect(within(dailyTask).getByText('1 / 1')).toBeTruthy(); + }); + expect(within(dailyTask).getByText('领取')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /每日任务/u })); expect(await screen.findByText('每日登录')).toBeTruthy(); @@ -1847,6 +1854,7 @@ test('profile daily task shortcut opens task center and claims reward', async () expect(await screen.findByText('已领取 10 泥点')).toBeTruthy(); expect(screen.queryByRole('button', { name: '已领取' })).toBeNull(); expect(screen.getByText('暂无任务')).toBeTruthy(); + expect(within(dailyTask).getByText('已完成')).toBeTruthy(); }); test('profile task center keeps only the highest priority actionable task', async () => { @@ -1909,7 +1917,7 @@ test('profile task center keeps only the highest priority actionable task', asyn expect(screen.queryByText('低优先级已完成')).toBeNull(); }); -test('profile total play time card always uses hours', () => { +test('profile total play time card always uses hours', async () => { renderProfileView(vi.fn(), { totalPlayTimeMs: 90 * 60 * 1000, }); @@ -1920,9 +1928,10 @@ test('profile total play time card always uses hours', () => { expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy(); expect(within(playTimeCard).queryByText('90分')).toBeNull(); + await screen.findByText('1 / 1'); }); -test('profile played works card shows count unit', () => { +test('profile played works card shows count unit', async () => { renderProfileView(vi.fn(), { playedWorldCount: 1, }); @@ -1932,9 +1941,10 @@ test('profile played works card shows count unit', () => { }); expect(within(playedCard).getByText('1个')).toBeTruthy(); + await screen.findByText('1 / 1'); }); -test('profile stats cards are centered without update timestamp', () => { +test('profile stats cards are centered without update timestamp', async () => { renderProfileView(vi.fn(), { updatedAt: '2026-05-03T08:01:00Z', }); @@ -1950,6 +1960,7 @@ test('profile stats cards are centered without update timestamp', () => { expect(card.className).toContain('text-center'); } expect(screen.queryByText(/更新于/u)).toBeNull(); + await screen.findByText('1 / 1'); }); test('mobile profile page matches the reference layout sections', async () => { @@ -2007,7 +2018,7 @@ test('mobile profile page matches the reference layout sections', async () => { expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy(); expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy(); expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点'); - expect(within(dailyTask).getByText('0 / 1')).toBeTruthy(); + expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy(); const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); expect( @@ -2101,7 +2112,7 @@ test('profile scan action opens camera scanner instead of recharge panel', async expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); }); -test('desktop account entry uses saved avatar image when available', () => { +test('desktop account entry uses saved avatar image when available', async () => { mockDesktopLayout(); const avatarUrl = 'data:image/png;base64,AAAA'; @@ -2111,6 +2122,7 @@ test('desktop account entry uses saved avatar image when available', () => { const avatarImage = accountEntry.querySelector('img'); expect(avatarImage?.getAttribute('src')).toBe(avatarUrl); expect(within(accountEntry).queryByText('测')).toBeNull(); + await screen.findByText('1 / 1'); }); test('profile avatar upload uses the shared square crop tool', async () => { @@ -2184,7 +2196,7 @@ test('profile invite shortcut shows reward subtitle and invited users', async () expect(screen.queryByText('今日')).toBeNull(); }); -test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => { +test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => { renderProfileView( vi.fn(), {}, @@ -2192,20 +2204,16 @@ test('profile redeem invite shortcut sits between invite and community for fresh ); const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); - const redeemButton = await screen.findByRole('button', { - name: /填邀请码/u, - }); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); - const secondaryShortcuts = screen.getByRole('region', { - name: '次级入口', + + await waitFor(() => { + expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1); }); expect(inviteButton).toBeTruthy(); expect(communityButton).toBeTruthy(); - expect( - within(secondaryShortcuts).getByRole('button', { name: /填邀请码/u }), - ).toBeTruthy(); - expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy(); + expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); + expect(screen.queryByRole('button', { name: /填邀请码/u })).toBeNull(); }); test('profile redeem invite shortcut hides after redeemed or one day old', async () => { @@ -2226,6 +2234,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async expect( within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }), ).toBeNull(); + await screen.findByText('1 / 1'); unmount(); renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' }); @@ -2237,6 +2246,7 @@ test('profile redeem invite shortcut hides after redeemed or one day old', async name: /填邀请码/u, }), ).toBeNull(); + await screen.findByText('1 / 1'); }); test('invite query opens login modal for logged out users', async () => { @@ -2269,9 +2279,10 @@ test('profile redeem invite modal reads query invite code after login', async () expect((input as HTMLInputElement).value).toBe('SPRING2026'); }); -test('profile redeem invite modal submits code and hides shortcut after success', async () => { +test('profile redeem invite query modal submits code after login', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); + window.history.replaceState(null, '', '/?inviteCode=spring-2026'); renderProfileView( onRechargeSuccess, @@ -2279,9 +2290,7 @@ test('profile redeem invite modal submits code and hides shortcut after success' { createdAt: buildFreshProfileCreatedAt() }, ); - await user.click(await screen.findByRole('button', { name: /填邀请码/u })); - const input = await screen.findByLabelText('邀请码'); - await user.type(input, 'spring-2026'); + expect(await screen.findByLabelText('邀请码')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '提交' })); await waitFor(() => { @@ -2291,12 +2300,7 @@ test('profile redeem invite modal submits code and hides shortcut after success' }); expect(onRechargeSuccess).toHaveBeenCalledTimes(1); expect(await screen.findByText('已填写')).toBeTruthy(); - const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); - expect( - within(shortcutRegion).queryByRole('button', { - name: /填邀请码/u, - }), - ).toBeNull(); + expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); }); test('opens reward code modal from profile action on mobile', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 506b9c01..ab961e97 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -255,18 +255,25 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; -const PROFILE_TASK_STATUS_PRIORITY_RANK: Record = { +const PROFILE_TASK_STATUS_PRIORITY_RANK: Record< + ProfileTaskItem['status'], + number +> = { claimable: 2, incomplete: 1, disabled: 0, claimed: -1, }; +const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10; const PROFILE_QR_SCAN_INTERVAL_MS = 360; function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) { return tasks .map((task, index) => ({ task, index })) - .filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete') + .filter( + ({ task }) => + task.status === 'claimable' || task.status === 'incomplete', + ) .sort( (left, right) => PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] - @@ -277,6 +284,37 @@ function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) { .map(({ task }) => task); } +function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) { + return ( + selectProfileTaskCenterTasks(tasks)[0] ?? + tasks.find((task) => task.status === 'claimed') ?? + tasks.find((task) => task.status !== 'disabled') ?? + null + ); +} + +function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) { + const task = selectProfileTaskCardTask(center?.tasks ?? []); + const threshold = Math.max(1, task?.threshold ?? 1); + const progressCount = Math.min(task?.progressCount ?? 0, threshold); + const rewardPoints = + task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS; + const actionLabel = + task?.status === 'claimable' + ? '领取' + : task?.status === 'claimed' + ? '已完成' + : '去完成'; + + return { + actionLabel, + progressCount, + progressPercent: Math.round((progressCount / threshold) * 100), + rewardPoints, + threshold, + }; +} + type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; type BarcodeDetectorLike = { @@ -2449,42 +2487,6 @@ function ProfileSettingsRow({ ); } -function ProfileSecondaryShortcutButton({ - label, - subLabel, - icon, - onClick, -}: { - label: string; - subLabel?: string; - icon: ComponentType<{ className?: string }>; - onClick: () => void; -}) { - const Icon = icon; - - return ( - - ); -} - function ProfileLegalSection({ onOpenDocument, }: { @@ -4218,12 +4220,10 @@ export function RpgEntryHomeView({ profileDashboard?.totalPlayTimeMs ?? 0, ); const playedWorkCount = profileDashboard?.playedWorldCount ?? 0; - const canShowReferralRedeemShortcut = - isAuthenticated && - isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) && - isReferralCenterInitialized && - Boolean(referralCenter) && - referralCenter?.hasRedeemedCode !== true; + const profileTaskCardSummary = useMemo( + () => buildProfileTaskCardSummary(taskCenter), + [taskCenter], + ); const tabIcons: Record< PlatformHomeTab, ComponentType<{ className?: string }> @@ -4776,7 +4776,7 @@ export function RpgEntryHomeView({ document.removeEventListener('visibilitychange', handleResume); }; }, [handleWechatPayResult]); - const loadTaskCenter = () => { + const loadTaskCenter = useCallback(() => { setTaskCenterError(null); setIsLoadingTaskCenter(true); void getRpgProfileTasks() @@ -4788,11 +4788,24 @@ export function RpgEntryHomeView({ ); }) .finally(() => setIsLoadingTaskCenter(false)); - }; + }, []); + + useEffect(() => { + if (activeTab !== 'profile' || !isAuthenticated) { + setTaskCenter(null); + setTaskCenterError(null); + return; + } + + loadTaskCenter(); + }, [activeTab, isAuthenticated, loadTaskCenter]); + const openTaskCenterPanel = () => { setIsTaskCenterOpen(true); setTaskClaimSuccess(null); - loadTaskCenter(); + if (!taskCenter) { + loadTaskCenter(); + } }; const openQrScannerPanel = () => { if (!authUi?.user) { @@ -6185,14 +6198,24 @@ export function RpgEntryHomeView({ 每日任务 - 完成任务可领取 10 泥点 + 完成任务可领取{' '} + + {profileTaskCardSummary.rewardPoints} + {' '} + 泥点 - 0 / 1 + {profileTaskCardSummary.progressCount} /{' '} + {profileTaskCardSummary.threshold} - + @@ -6202,7 +6225,7 @@ export function RpgEntryHomeView({ className="platform-profile-daily-task-card__mascot" /> - 去完成 + {profileTaskCardSummary.actionLabel} @@ -6267,20 +6290,6 @@ export function RpgEntryHomeView({ /> - {canShowReferralRedeemShortcut ? ( -
- openProfilePopupPanel('redeem')} - /> -
- ) : null} - ) : ( From abea7cec1d28a4250dab8905b33788d56ab9740a Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 26 May 2026 15:57:39 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E5=B9=B3=E5=8F=B0=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=B8=8E=E5=AE=8C=E6=88=90=E5=BC=B9=E7=AA=97=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 143 +++++++++++++++++- .../PlatformErrorDialog.test.tsx | 47 ++++++ .../PlatformTaskCompletionDialog.tsx | 124 +++++++++++++++ ...gEntryFlowShell.agent.interaction.test.tsx | 34 ++++- 6 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 src/components/platform-entry/PlatformTaskCompletionDialog.tsx diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 070fd6ca..51c774ff 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx`、`npm run typecheck`、`npm run check:encoding` 通过;手测时异步失败应弹出包含“错误来源”和“错误内容”的弹窗,复制按钮应复制完整诊断文本。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-05-26 生成任务完成在离开生成页后弹独立完成弹窗 + +- 背景:抓大鹅、拼图等生成任务完成时,用户如果已经离开生成页,草稿页的未读红点不足以表达“这次生成已完成”;但如果用户仍停留在生成页,结果页或试玩页本身就是完成反馈,不需要再叠一个成功提示。 +- 决策:平台壳层在 `markDraftReady(..., viewedImmediately=false)` 时额外弹出 `PlatformTaskCompletionDialog`,完成弹窗必须带来源和复制按钮;如果 `viewedImmediately=true`,只保留结果页 / 试玩页本身的完成反馈和草稿未读态,不重复弹窗。 +- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformTaskCompletionDialog.tsx`、`src/components/platform-entry/PlatformErrorDialog.test.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "completed match3d draft"` 通过后,离开生成页再完成的草稿应出现“生成完成”弹窗,且复制内容包含来源与状态。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-26 “我的”页任务卡读后端任务摘要并移除常驻填邀请码入口 - 背景:移动端“我的”页每日任务卡曾硬编码 `0 / 1`,任务领取完成后只刷新弹窗内任务中心,卡片本身不更新;页面底部还保留旧的“填邀请码”次级按钮,和当前五项常用功能宫格口径重复。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 600e0327..9899a199 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -14,6 +14,8 @@ 平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。 +生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。 + `PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 `platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recent` / `最近创作`,避免旧数据、局部 mock 或异常返回把创作入口初始化直接打崩。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a74239f8..19c1fecd 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -429,6 +429,10 @@ import { PlatformErrorDialog, type PlatformErrorDialogPayload, } from './PlatformErrorDialog'; +import { + PlatformTaskCompletionDialog, + type PlatformTaskCompletionDialogPayload, +} from './PlatformTaskCompletionDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; @@ -444,6 +448,7 @@ type DraftGenerationNoticeStatus = 'generating' | 'ready'; type DraftGenerationNotice = { status: DraftGenerationNoticeStatus; seen: boolean; + completedAtMs?: number; }; type DraftGenerationNoticeMap = Record; type CreationWorkShelfKind = CreationWorkShelfItem['kind']; @@ -2027,12 +2032,74 @@ function formatPlatformErrorSource(label: string, id?: string | null) { return normalizedId ? `${label} ${normalizedId}` : label; } +function formatPlatformTaskCompletionSource(label: string, id?: string | null) { + const normalizedId = id?.trim(); + return normalizedId ? `${label} ${normalizedId}` : label; +} + function buildPlatformErrorDialogDismissKey( error: (PlatformErrorDialogPayload & { key: string }) | null, ) { return error ? `${error.key}:${error.source}:${error.message}` : null; } +function buildPlatformTaskCompletionDialogDismissKey( + completion: + | (PlatformTaskCompletionDialogPayload & { + key: string; + completedAtMs: number | null; + }) + | null, +) { + return completion + ? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}` + : null; +} + +function pickDraftCompletionDialogSourceId( + ids: Array, +) { + const normalizedIds = ids + .map((id) => id?.trim() ?? '') + .filter((id) => Boolean(id)); + return ( + normalizedIds.find((id) => /session/i.test(id)) ?? + normalizedIds.find((id) => /work/i.test(id)) ?? + normalizedIds.find((id) => /draft/i.test(id)) ?? + normalizedIds.find((id) => /run/i.test(id)) ?? + normalizedIds.find((id) => /profile/i.test(id)) ?? + normalizedIds[0] ?? + null + ); +} + +function buildDraftCompletionDialogSource( + kind: CreationWorkShelfKind, + ids: Array, +) { + const sourceId = pickDraftCompletionDialogSourceId(ids); + switch (kind) { + case 'rpg': + return formatPlatformTaskCompletionSource('RPG 草稿', sourceId); + case 'big-fish': + return formatPlatformTaskCompletionSource('大鱼吃小鱼草稿', sourceId); + case 'match3d': + return formatPlatformTaskCompletionSource('抓大鹅草稿', sourceId); + case 'square-hole': + return formatPlatformTaskCompletionSource('方洞挑战草稿', sourceId); + case 'jump-hop': + return formatPlatformTaskCompletionSource('跳一跳草稿', sourceId); + case 'puzzle': + return formatPlatformTaskCompletionSource('拼图草稿', sourceId); + case 'visual-novel': + return formatPlatformTaskCompletionSource('视觉小说草稿', sourceId); + case 'bark-battle': + return formatPlatformTaskCompletionSource('汪汪声浪草稿', sourceId); + case 'baby-object-match': + return formatPlatformTaskCompletionSource('宝贝识物草稿', sourceId); + } +} + function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, metadata?: MiniGameDraftGenerationState['metadata'], @@ -3327,6 +3394,16 @@ export function PlatformEntryFlowShellImpl({ useState({}); const [pendingDraftShelfItems, setPendingDraftShelfItems] = useState({}); + const [ + pendingPlatformTaskCompletionDialog, + setPendingPlatformTaskCompletionDialog, + ] = useState< + | (PlatformTaskCompletionDialogPayload & { + key: string; + completedAtMs: number | null; + }) + | null + >(null); const [initialCreationUrlState] = useState(() => readCreationUrlState()); const handledInitialCreationUrlStateRef = useRef(false); const [initialPuzzleRuntimeUrlState] = useState(() => @@ -3404,10 +3481,14 @@ export function PlatformEntryFlowShellImpl({ return; } + const completedAtMs = status === 'ready' ? Date.now() : undefined; setDraftGenerationNotices((current) => { const next = { ...current }; for (const key of uniqueKeys) { - next[key] = { status, seen }; + next[key] = + completedAtMs === undefined + ? { status, seen } + : { status, seen, completedAtMs }; } return next; }); @@ -3449,12 +3530,13 @@ export function PlatformEntryFlowShellImpl({ ); const markDraftGenerating = useCallback( (kind: CreationWorkShelfKind, ids: Array) => { + setPendingPlatformTaskCompletionDialog(null); updateDraftGenerationNotices( collectDraftNoticeKeys(kind, ids), 'generating', ); }, - [updateDraftGenerationNotices], + [setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices], ); const markDraftReady = useCallback( ( @@ -3467,17 +3549,27 @@ export function PlatformEntryFlowShellImpl({ 'ready', viewedImmediately, ); + if (!viewedImmediately) { + const completedAtMs = Date.now(); + setPendingPlatformTaskCompletionDialog({ + key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`, + source: buildDraftCompletionDialogSource(kind, ids), + message: '生成任务已完成,可以继续查看草稿。', + completedAtMs, + }); + } }, - [updateDraftGenerationNotices], + [setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices], ); const markPendingDraftGenerating = useCallback( ( kind: Exclude, id: string | null | undefined, ) => { + setPendingPlatformTaskCompletionDialog(null); updatePendingDraftShelfItem(kind, id, 'generating'); }, - [updatePendingDraftShelfItem], + [setPendingPlatformTaskCompletionDialog, updatePendingDraftShelfItem], ); const markPendingDraftReady = useCallback( ( @@ -5790,6 +5882,10 @@ export function PlatformEntryFlowShellImpl({ ); const [dismissedPlatformErrorDialogKey, setDismissedPlatformErrorDialogKey] = useState(null); + const [ + dismissedPlatformTaskCompletionDialogKey, + setDismissedPlatformTaskCompletionDialogKey, + ] = useState(null); const currentPlatformErrorDialog = useMemo< (PlatformErrorDialogPayload & { key: string }) | null >(() => { @@ -6013,6 +6109,25 @@ export function PlatformEntryFlowShellImpl({ woodenFishRun?.runId, woodenFishSession?.sessionId, ]); + const currentPlatformTaskCompletionDialog = useMemo< + | (PlatformTaskCompletionDialogPayload & { + key: string; + completedAtMs: number | null; + }) + | null + >(() => pendingPlatformTaskCompletionDialog, [ + pendingPlatformTaskCompletionDialog, + ]); + const activePlatformTaskCompletionDialogDismissKey = + buildPlatformTaskCompletionDialogDismissKey( + currentPlatformTaskCompletionDialog, + ); + const activePlatformTaskCompletionDialog = + activePlatformTaskCompletionDialogDismissKey && + activePlatformTaskCompletionDialogDismissKey === + dismissedPlatformTaskCompletionDialogKey + ? null + : currentPlatformTaskCompletionDialog; const activePlatformErrorDialogDismissKey = buildPlatformErrorDialogDismissKey(currentPlatformErrorDialog); const activePlatformErrorDialog = @@ -6118,6 +6233,19 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleError, setVisualNovelError, ]); + const closePlatformTaskCompletionDialog = useCallback(() => { + if (!currentPlatformTaskCompletionDialog) { + return; + } + + const dismissKey = buildPlatformTaskCompletionDialogDismissKey( + currentPlatformTaskCompletionDialog, + ); + if (dismissKey) { + setDismissedPlatformTaskCompletionDialogKey(dismissKey); + } + setPendingPlatformTaskCompletionDialog(null); + }, [currentPlatformTaskCompletionDialog]); const shouldPollPuzzleGenerationSession = selectionStage === 'puzzle-generating' && activePuzzleGenerationSessionId != null && @@ -7116,6 +7244,7 @@ export function PlatformEntryFlowShellImpl({ setIsProfilePlayStatsOpen(false); setDraftGenerationNotices({}); setPendingDraftShelfItems({}); + setPendingPlatformTaskCompletionDialog(null); resetRpgSessionViewState(); setRpgGeneratedCustomWorldProfile(null); setRpgCustomWorldError(null); @@ -16871,6 +17000,12 @@ export function PlatformEntryFlowShellImpl({ overlayClassName={`platform-theme ${platformThemeClass} !items-center`} panelClassName="platform-remap-surface rounded-[1.5rem]" /> + ({ copyTextToClipboard: vi.fn(), @@ -58,3 +59,49 @@ describe('PlatformErrorDialog', () => { expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull(); }); }); + +describe('PlatformTaskCompletionDialog', () => { + test('shows source, message, and copies the full completion report', async () => { + vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true); + + render( + {}} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '生成完成' }); + expect( + within(dialog).getByText('抓大鹅草稿 match3d-notice-session-1'), + ).toBeTruthy(); + expect( + within(dialog).getByText('生成任务已完成,可以继续查看草稿。'), + ).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '复制内容' })); + + expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( + [ + '来源:抓大鹅草稿 match3d-notice-session-1', + '状态:生成任务已完成,可以继续查看草稿。', + ].join('\n'), + ); + await waitFor(() => { + expect( + within(dialog).getByRole('button', { name: '已复制' }), + ).toBeTruthy(); + }); + }); + + test('does not render when there is no active completion', () => { + render( + {}} />, + ); + + expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull(); + }); +}); diff --git a/src/components/platform-entry/PlatformTaskCompletionDialog.tsx b/src/components/platform-entry/PlatformTaskCompletionDialog.tsx new file mode 100644 index 00000000..66513dd9 --- /dev/null +++ b/src/components/platform-entry/PlatformTaskCompletionDialog.tsx @@ -0,0 +1,124 @@ +import { CheckCircle2, Copy } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { copyTextToClipboard } from '../../services/clipboard'; +import { UnifiedModal } from '../common/UnifiedModal'; + +export type PlatformTaskCompletionDialogPayload = { + source: string; + message: string; +}; + +type PlatformTaskCompletionDialogProps = { + completion: PlatformTaskCompletionDialogPayload | null; + onClose: () => void; + overlayClassName?: string; + panelClassName?: string; +}; + +function buildPlatformTaskCompletionReport( + completion: PlatformTaskCompletionDialogPayload, +) { + return [`来源:${completion.source}`, `状态:${completion.message}`].join( + '\n', + ); +} + +export function PlatformTaskCompletionDialog({ + completion, + onClose, + overlayClassName = 'platform-theme platform-theme--light !items-center', + panelClassName = 'platform-remap-surface rounded-[1.5rem]', +}: PlatformTaskCompletionDialogProps) { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const resetTimerRef = useRef(null); + const reportText = useMemo( + () => (completion ? buildPlatformTaskCompletionReport(completion) : ''), + [completion], + ); + + useEffect( + () => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, + [], + ); + + useEffect(() => { + setCopyState('idle'); + }, [completion?.source, completion?.message]); + + const copyCompletion = () => { + if (!reportText) { + return; + } + + void copyTextToClipboard(reportText).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + resetTimerRef.current = null; + setCopyState('idle'); + }, 1400); + }); + }; + + return ( + + {copyState === 'copied' ? ( + + ) : ( + + )} + {copyState === 'copied' + ? '已复制' + : copyState === 'failed' + ? '复制失败' + : '复制内容'} + + } + > + {completion ? ( + <> +
+
+ 来源 +
+
+ {completion.source} +
+
+
+
+ 状态 +
+
+ {completion.message} +
+
+ + ) : null} +
+ ); +} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index baf9807c..d6941800 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -3757,7 +3757,7 @@ test('running match3d form generation can return to draft tab and reopen progres render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); @@ -3841,7 +3841,7 @@ test('running match3d persisted draft reopens progress instead of unfinished res render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); @@ -4038,7 +4038,7 @@ test('running match3d form generation keeps other creation templates available', render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); @@ -4107,7 +4107,7 @@ test('running match3d form generation keeps same template generation available', render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); @@ -4721,7 +4721,7 @@ test('match3d draft generation auto starts trial and runtime back opens draft re render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); @@ -4953,11 +4953,15 @@ test('completed match3d draft notice first opens trial then reopens result', asy render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); await expectDraftHubGeneratingBadgeCountAtLeast(1); @@ -4966,6 +4970,22 @@ test('completed match3d draft notice first opens trial then reopens result', asy resolveCompile({ session: generatedSession }); }); + const completionDialog = await screen.findByRole('dialog', { + name: '生成完成', + }); + expect( + within(completionDialog).getByText( + /抓大鹅草稿 match3d-notice-session-1/u, + ), + ).toBeTruthy(); + expect( + within(completionDialog).getByText(/生成任务已完成/u), + ).toBeTruthy(); + expect( + within(completionDialog).getByRole('button', { name: '复制内容' }), + ).toBeTruthy(); + await user.click(within(completionDialog).getByLabelText('关闭')); + expect(await screen.findByLabelText('新生成完成')).toBeTruthy(); await user.click( await screen.findByRole('button', { From 4001ee0a5c61b48a58d3e0399de5325857697a32 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 26 May 2026 19:40:23 +0800 Subject: [PATCH 4/5] Add platform generation dialogs and task refresh --- .../PlatformEntryFlowShellImpl.tsx | 19 +++++---- ...gEntryFlowShell.agent.interaction.test.tsx | 42 +++++++++++++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 4 +- src/components/rpg-entry/rpgEntryShared.ts | 3 ++ 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 19c1fecd..8b43f4c4 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -3404,6 +3404,7 @@ export function PlatformEntryFlowShellImpl({ }) | null >(null); + const [profileTaskRefreshKey, setProfileTaskRefreshKey] = useState(0); const [initialCreationUrlState] = useState(() => readCreationUrlState()); const handledInitialCreationUrlStateRef = useRef(false); const [initialPuzzleRuntimeUrlState] = useState(() => @@ -3549,15 +3550,14 @@ export function PlatformEntryFlowShellImpl({ 'ready', viewedImmediately, ); - if (!viewedImmediately) { - const completedAtMs = Date.now(); - setPendingPlatformTaskCompletionDialog({ - key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`, - source: buildDraftCompletionDialogSource(kind, ids), - message: '生成任务已完成,可以继续查看草稿。', - completedAtMs, - }); - } + setProfileTaskRefreshKey((current) => current + 1); + const completedAtMs = Date.now(); + setPendingPlatformTaskCompletionDialog({ + key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`, + source: buildDraftCompletionDialogSource(kind, ids), + message: '生成任务已完成,可以继续查看草稿。', + completedAtMs, + }); }, [setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices], ); @@ -14755,6 +14755,7 @@ export function PlatformEntryFlowShellImpl({ isLoadingPlatform={platformBootstrap.isLoadingPlatform} isLoadingDashboard={platformBootstrap.isLoadingDashboard} hasUnreadDraftUpdate={hasUnreadDraftUpdates} + profileTaskRefreshKey={profileTaskRefreshKey} isDesktopLayout={isDesktopLayout} isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey} platformError={ diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index d6941800..1d0ab9ae 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -7137,6 +7137,48 @@ test('persisted generating puzzle draft keeps session polling on the same sessio expect(getPuzzleAgentSession).toHaveBeenCalledTimes(2); }); +test('puzzle compile timeout shows failure dialog when reread session is still generating', async () => { + const user = userEvent.setup(); + const runningSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-session-timeout', + draft: null, + stage: 'collecting_anchors', + progressPercent: 88, + lastAssistantReply: '正在生成拼图草稿。', + }); + vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({ + session: runningSession, + }); + vi.mocked(executePuzzleAgentAction).mockRejectedValueOnce( + Object.assign(new Error('请求超时:1800000ms'), { + name: 'TimeoutError', + }), + ); + vi.mocked(getPuzzleAgentSession).mockResolvedValue({ + session: runningSession, + }); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + await user.click(await screen.findByRole('button', { name: '生成草稿' })); + + const dialog = await screen.findByRole('dialog', { name: '发生错误' }); + expect(within(dialog).getByText('拼图草稿 puzzle-session-timeout')).toBeTruthy(); + expect( + within(dialog).getByText( + '拼图共创操作超时,请确认运行时后端已启动后重试。', + ), + ).toBeTruthy(); + expect(within(dialog).getByRole('button', { name: '复制报错' })).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); +}); + test('published puzzle work card restores its source session for editing', async () => { const user = userEvent.setup(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index ab961e97..851e1ca1 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -216,6 +216,7 @@ export interface RpgEntryHomeViewProps { onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void; onOpenFeedback?: () => void; onRechargeSuccess?: () => void | Promise; + profileTaskRefreshKey?: number; createTabContent?: ReactNode; draftTabContent?: ReactNode; hasUnreadDraftUpdate?: boolean; @@ -3983,6 +3984,7 @@ export function RpgEntryHomeView({ onOpenPlayedWork, onOpenFeedback, onRechargeSuccess, + profileTaskRefreshKey = 0, createTabContent, draftTabContent, hasUnreadDraftUpdate = false, @@ -4798,7 +4800,7 @@ export function RpgEntryHomeView({ } loadTaskCenter(); - }, [activeTab, isAuthenticated, loadTaskCenter]); + }, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]); const openTaskCenterPanel = () => { setIsTaskCenterOpen(true); diff --git a/src/components/rpg-entry/rpgEntryShared.ts b/src/components/rpg-entry/rpgEntryShared.ts index 55537e39..2193bb8e 100644 --- a/src/components/rpg-entry/rpgEntryShared.ts +++ b/src/components/rpg-entry/rpgEntryShared.ts @@ -9,6 +9,9 @@ import type { CustomWorldProfile } from '../../types'; export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) { if (isTimeoutError(error)) { + if (/拼图/u.test(fallback) && /操作|执行|编译|生成草稿/u.test(fallback)) { + return '拼图共创操作超时,请确认运行时后端已启动后重试。'; + } if (/智能创作/u.test(fallback)) { return '开启智能创作工作区超时,请确认运行时后端已启动后重试。'; } From 17a184b0a777b98529bdb59be65de15a9c4411ad Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 26 May 2026 22:02:58 +0800 Subject: [PATCH 5/5] fix: settle puzzle failures and profile tasks --- server-rs/crates/api-server/src/puzzle.rs | 23 ++-- .../crates/api-server/src/puzzle/handlers.rs | 46 ++++++- .../crates/module-puzzle/src/application.rs | 60 +++++++++ .../crates/module-puzzle/src/commands.rs | 9 ++ server-rs/crates/spacetime-client/src/lib.rs | 3 +- .../crates/spacetime-client/src/mapper.rs | 3 +- .../spacetime-client/src/mapper/puzzle.rs | 8 ++ .../spacetime-client/src/module_bindings.rs | 6 +- ...uzzle_draft_generation_failed_procedure.rs | 59 +++++++++ ...puzzle_draft_compile_failure_input_type.rs | 18 +++ .../crates/spacetime-client/src/puzzle.rs | 30 +++++ .../crates/spacetime-module/src/puzzle.rs | 93 +++++++++++-- .../RpgEntryHomeView.recharge.test.tsx | 124 ++++++++++++------ src/components/rpg-entry/RpgEntryHomeView.tsx | 35 +++-- 14 files changed, 436 insertions(+), 81 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_draft_generation_failed_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 67453bca..56cc3b73 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -55,17 +55,18 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, - PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, - PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, - PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, - PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, - PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, - PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, - PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, - PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, - PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, - PuzzleWorkUpsertRecordInput, SpacetimeClientError, + PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, + PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, + PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, + PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, + PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, + PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, + PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, + PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, + SpacetimeClientError, }; use std::convert::Infallible; diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 46834284..fe902640 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -606,6 +606,36 @@ pub async fn execute_puzzle_agent_action( ), "拼图 Agent action 开始执行" ); + + let mark_puzzle_compile_failure = |error: &AppError, compile_session_id: &str| { + let state = state.clone(); + let owner_user_id = owner_user_id.clone(); + let error_message = error.body_text(); + let session_id = compile_session_id.to_string(); + let log_session_id = session_id.clone(); + let log_owner_user_id = owner_user_id.clone(); + async move { + let result = state + .spacetime_client() + .mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput { + session_id, + owner_user_id, + error_message, + failed_at_micros: now, + }) + .await; + if let Err(error) = result { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %log_session_id, + owner_user_id = %log_owner_user_id, + message = %error, + "拼图草稿失败态回写失败,继续返回原始错误" + ); + } + } + }; + let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { let ai_redraw = payload.ai_redraw.unwrap_or(true); @@ -666,10 +696,18 @@ pub async fn execute_puzzle_agent_action( now, ) .await - } - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - }); + }; + let session = match session { + Ok(session) => Ok(session), + Err(error) => { + mark_puzzle_compile_failure(&error, &compile_session_id).await; + Err(puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + error, + )) + } + }; ( "compile_puzzle_draft", "首关拼图草稿", diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index a3cdfa8b..e0ee1dda 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -388,6 +388,22 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft draft } +pub fn mark_failed_puzzle_result_draft_generation( + mut draft: PuzzleResultDraft, +) -> PuzzleResultDraft { + if draft.levels.is_empty() { + draft = normalize_puzzle_draft(draft); + } + for level in &mut draft.levels { + if level.generation_status.trim() != "ready" { + level.generation_status = "failed".to_string(); + } + } + sync_primary_level_fields(&mut draft); + draft.generation_status = "failed".to_string(); + draft +} + pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) { if let Some(primary_level) = draft.levels.first() { draft.level_name = primary_level.level_name.clone(); @@ -3212,6 +3228,50 @@ mod tests { ); } + #[test] + fn failed_generation_marks_pending_levels_failed_without_touching_ready_assets() { + let anchor_pack = infer_anchor_pack("雨夜猫街", Some("雨夜猫街")); + let mut draft = compile_result_draft(&anchor_pack, &[]); + draft.generation_status = "generating".to_string(); + draft.levels[0].generation_status = "generating".to_string(); + draft.levels.push(PuzzleDraftLevel { + level_id: "puzzle-level-2".to_string(), + level_name: "第二关".to_string(), + picture_description: "第二关画面".to_string(), + picture_reference: None, + ui_background_prompt: None, + ui_background_image_src: None, + ui_background_image_object_key: None, + level_scene_image_src: None, + level_scene_image_object_key: None, + ui_spritesheet_image_src: None, + ui_spritesheet_image_object_key: None, + level_background_image_src: None, + level_background_image_object_key: None, + background_music: None, + candidates: vec![PuzzleGeneratedImageCandidate { + candidate_id: "candidate-1".to_string(), + image_src: "/ready.png".to_string(), + asset_id: "asset-1".to_string(), + prompt: "prompt".to_string(), + actual_prompt: None, + source_type: "generated".to_string(), + selected: true, + }], + selected_candidate_id: Some("candidate-1".to_string()), + cover_image_src: Some("/ready.png".to_string()), + cover_asset_id: Some("asset-1".to_string()), + generation_status: "ready".to_string(), + }); + + let failed = mark_failed_puzzle_result_draft_generation(draft); + + assert_eq!(failed.generation_status, "failed"); + assert_eq!(failed.levels[0].generation_status, "failed"); + assert_eq!(failed.levels[1].generation_status, "ready"); + assert_eq!(failed.levels[1].cover_image_src.as_deref(), Some("/ready.png")); + } + #[test] fn form_seed_keeps_multiline_picture_description() { let anchor_pack = infer_anchor_pack( diff --git a/server-rs/crates/module-puzzle/src/commands.rs b/server-rs/crates/module-puzzle/src/commands.rs index 66cf0b1a..a1be2445 100644 --- a/server-rs/crates/module-puzzle/src/commands.rs +++ b/server-rs/crates/module-puzzle/src/commands.rs @@ -68,6 +68,15 @@ pub struct PuzzleDraftCompileInput { pub compiled_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleDraftCompileFailureInput { + pub session_id: String, + pub owner_user_id: String, + pub error_message: String, + pub failed_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleGeneratedImagesSaveInput { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 5bd54ff4..51e9ca43 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -52,7 +52,8 @@ pub use mapper::{ PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, - PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, + PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index df2d43b4..3a9c0d85 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -99,7 +99,8 @@ pub use self::puzzle::{ PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, - PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, + PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index 0b7d8ec6..46e151f4 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -636,6 +636,14 @@ pub struct PuzzleAgentMessageFinalizeRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleDraftCompileFailureRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub error_message: String, + pub failed_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleGeneratedImagesSaveRecordInput { pub session_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 3aa8dc89..f7f1a2e8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). +// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -468,6 +468,7 @@ pub mod list_visual_novel_runtime_history_procedure; pub mod list_visual_novel_works_procedure; pub mod list_wooden_fish_works_procedure; pub mod mark_profile_recharge_order_paid_and_return_procedure; +pub mod mark_puzzle_draft_generation_failed_procedure; pub mod match_3_d_agent_message_finalize_input_type; pub mod match_3_d_agent_message_row_type; pub mod match_3_d_agent_message_snapshot_type; @@ -597,6 +598,7 @@ pub mod puzzle_audio_asset_type; pub mod puzzle_board_snapshot_type; pub mod puzzle_cell_position_type; pub mod puzzle_creator_intent_type; +pub mod puzzle_draft_compile_failure_input_type; pub mod puzzle_draft_compile_input_type; pub mod puzzle_draft_level_type; pub mod puzzle_event_kind_type; @@ -1497,6 +1499,7 @@ pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_h pub use list_visual_novel_works_procedure::list_visual_novel_works; pub use list_wooden_fish_works_procedure::list_wooden_fish_works; pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; +pub use mark_puzzle_draft_generation_failed_procedure::mark_puzzle_draft_generation_failed; pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow; pub use match_3_d_agent_message_snapshot_type::Match3DAgentMessageSnapshot; @@ -1626,6 +1629,7 @@ pub use puzzle_audio_asset_type::PuzzleAudioAsset; pub use puzzle_board_snapshot_type::PuzzleBoardSnapshot; pub use puzzle_cell_position_type::PuzzleCellPosition; pub use puzzle_creator_intent_type::PuzzleCreatorIntent; +pub use puzzle_draft_compile_failure_input_type::PuzzleDraftCompileFailureInput; pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput; pub use puzzle_draft_level_type::PuzzleDraftLevel; pub use puzzle_event_kind_type::PuzzleEventKind; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_draft_generation_failed_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_draft_generation_failed_procedure.rs new file mode 100644 index 00000000..ae073d5c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_draft_generation_failed_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult; +use super::puzzle_draft_compile_failure_input_type::PuzzleDraftCompileFailureInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct MarkPuzzleDraftGenerationFailedArgs { + pub input: PuzzleDraftCompileFailureInput, +} + +impl __sdk::InModule for MarkPuzzleDraftGenerationFailedArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `mark_puzzle_draft_generation_failed`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait mark_puzzle_draft_generation_failed { + fn mark_puzzle_draft_generation_failed(&self, input: PuzzleDraftCompileFailureInput) { + self.mark_puzzle_draft_generation_failed_then(input, |_, _| {}); + } + + fn mark_puzzle_draft_generation_failed_then( + &self, + input: PuzzleDraftCompileFailureInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl mark_puzzle_draft_generation_failed for super::RemoteProcedures { + fn mark_puzzle_draft_generation_failed_then( + &self, + input: PuzzleDraftCompileFailureInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>( + "mark_puzzle_draft_generation_failed", + MarkPuzzleDraftGenerationFailedArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs new file mode 100644 index 00000000..ccda3ff5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleDraftCompileFailureInput { + pub session_id: String, + pub owner_user_id: String, + pub error_message: String, + pub failed_at_micros: i64, +} + +impl __sdk::InModule for PuzzleDraftCompileFailureInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index f6ddd839..1fb3e62c 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -167,6 +167,36 @@ impl SpacetimeClient { .await } + pub async fn mark_puzzle_draft_generation_failed( + &self, + input: PuzzleDraftCompileFailureRecordInput, + ) -> Result { + let procedure_input = PuzzleDraftCompileFailureInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + error_message: input.error_message, + failed_at_micros: input.failed_at_micros, + }; + + self.call_after_connect( + "mark_puzzle_draft_generation_failed", + move |connection, sender| { + connection + .procedures() + .mark_puzzle_draft_generation_failed_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn save_puzzle_generated_images( &self, input: PuzzleGeneratedImagesSaveRecordInput, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a22ed976..60c2ae83 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -11,20 +11,20 @@ use module_puzzle::{ PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, - PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, - PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, - PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, - PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput, - PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, - PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput, - PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, + PuzzleDraftCompileFailureInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, + PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, + PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, + PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, + PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, + PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed, build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, - normalize_puzzle_draft, normalize_puzzle_levels, normalize_theme_tags, publish_work_profile, - replace_puzzle_level, select_next_profiles, selected_profile_level_after_runtime_level, - selected_puzzle_level, tag_similarity_score, + mark_failed_puzzle_result_draft_generation, normalize_puzzle_draft, normalize_puzzle_levels, + normalize_theme_tags, publish_work_profile, replace_puzzle_level, select_next_profiles, + selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score, }; use module_runtime::RuntimeProfileWalletLedgerSourceType; use module_runtime::visible_runtime_profile_user_tags; @@ -363,6 +363,25 @@ pub fn compile_puzzle_agent_draft( } } +#[spacetimedb::procedure] +pub fn mark_puzzle_draft_generation_failed( + ctx: &mut ProcedureContext, + input: PuzzleDraftCompileFailureInput, +) -> PuzzleAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| mark_puzzle_draft_generation_failed_tx(tx, input.clone())) { + Ok(session) => PuzzleAgentSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => PuzzleAgentSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + /// 保存拼图入口表单草稿。 /// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。 #[spacetimedb::procedure] @@ -999,6 +1018,60 @@ fn compile_puzzle_agent_draft_tx( ) } +fn mark_puzzle_draft_generation_failed_tx( + ctx: &TxContext, + input: PuzzleDraftCompileFailureInput, +) -> Result { + let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros); + let draft = match deserialize_optional_draft(&row.draft_json)? { + Some(draft) => mark_failed_puzzle_result_draft_generation(draft), + None => { + let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?; + let messages = list_session_messages(ctx, &row.session_id); + mark_failed_puzzle_result_draft_generation(compile_result_draft_from_seed( + &anchor_pack, + &messages, + Some(&row.seed_text), + )) + } + }; + upsert_puzzle_draft_work_profile( + ctx, + &row.session_id, + &row.owner_user_id, + &draft, + input.failed_at_micros, + )?; + + replace_puzzle_agent_session( + ctx, + &row, + PuzzleAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent.max(88), + stage: row.stage, + anchor_pack_json: row.anchor_pack_json.clone(), + draft_json: Some(serialize_json(&draft)), + last_assistant_reply: Some(input.error_message), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at, + }, + ); + + get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + fn save_puzzle_form_draft_tx( ctx: &TxContext, input: PuzzleFormDraftSaveInput, diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index f336c6d6..e6646295 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -702,14 +702,22 @@ function mockNarrowMobileLayout() { }); } -function renderProfileView( +function ProfileHomeViewHarness({ onRechargeSuccess = vi.fn(), - profileDashboardOverrides: Partial< + profileDashboardOverrides = {}, + userOverrides = {}, + activeTab = 'profile', + profileTaskRefreshKey = 0, +}: { + onRechargeSuccess?: () => void | Promise; + profileDashboardOverrides?: Partial< NonNullable - > = {}, - userOverrides: Partial = {}, -) { - return render( + >; + userOverrides?: Partial; + activeTab?: RpgEntryHomeViewProps['activeTab']; + profileTaskRefreshKey?: number; +}) { + return ( - , + + ); +} + +function renderProfileView( + onRechargeSuccess = vi.fn(), + profileDashboardOverrides: Partial< + NonNullable + > = {}, + userOverrides: Partial = {}, + profileTaskRefreshKey = 0, +) { + return render( + , ); } @@ -2026,7 +2053,7 @@ test('mobile profile page matches the reference layout sections', async () => { ).toBeTruthy(); expect( shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'), - ).toHaveLength(5); + ).toHaveLength(4); expect( shortcutRegion .querySelector('.platform-profile-shortcut-grid') @@ -2034,7 +2061,6 @@ test('mobile profile page matches the reference layout sections', async () => { ).toBe(true); for (const label of [ '泥点充值', - '邀请好友', '兑换码', '玩家社区', '反馈与建议', @@ -2172,28 +2198,25 @@ test('wallet ledger modal shows empty and error states', async () => { expect(screen.getByText('重新加载')).toBeTruthy(); }); -test('profile invite shortcut shows reward subtitle and invited users', async () => { +test('profile community shortcut shows reward subtitle and invited users', async () => { const user = userEvent.setup(); renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() }); - const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); - expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy(); + expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull(); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy(); - await user.click(inviteButton); + await user.click(communityButton); expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1); - expect( - await screen.findByText('邀请一个用户注册,双方都可以获得30泥点。'), - ).toBeTruthy(); - expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy(); - expect(screen.getByText('成功邀请')).toBeTruthy(); - expect(screen.getByText('被邀请玩家')).toBeTruthy(); - expect(screen.queryByText('已奖')).toBeNull(); - expect(screen.queryByText('今日')).toBeNull(); + expect(screen.getByAltText('玩家社区微信群二维码')).toBeTruthy(); + expect(screen.getByAltText('玩家社区 QQ 群二维码')).toBeTruthy(); + expect(screen.getByText('微信群')).toBeTruthy(); + expect(screen.getByText('QQ群')).toBeTruthy(); + expect(screen.queryByText('成功邀请')).toBeNull(); + expect(screen.queryByText('被邀请玩家')).toBeNull(); }); test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => { @@ -2203,50 +2226,55 @@ test('profile page hides legacy redeem invite secondary shortcut for fresh accou { createdAt: buildFreshProfileCreatedAt() }, ); - const inviteButton = screen.getByRole('button', { name: /邀请好友/u }); const communityButton = screen.getByRole('button', { name: /玩家社区/u }); await waitFor(() => { expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1); }); - expect(inviteButton).toBeTruthy(); + expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull(); expect(communityButton).toBeTruthy(); expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); expect(screen.queryByRole('button', { name: /填邀请码/u })).toBeNull(); }); test('profile redeem invite shortcut hides after redeemed or one day old', async () => { - const user = userEvent.setup(); - - mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce( - mockBuildReferralCenter({ - invitedUsers: [], - hasRedeemedCode: true, - boundInviterUserId: 'user-2', - boundAt: '2026-05-01T08:00:00Z', - }), - ); const { unmount } = renderProfileView(); - await user.click(screen.getByRole('button', { name: /邀请好友/u })); - await screen.findByText('成功邀请'); const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' }); + expect( + within(firstShortcutRegion).queryByRole('button', { name: /邀请好友/u }), + ).toBeNull(); expect( within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }), ).toBeNull(); - await screen.findByText('1 / 1'); + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1); + }); + await act(async () => { + await Promise.resolve(); + }); unmount(); renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' }); const expiredShortcutRegion = screen.getByRole('region', { name: '常用功能', }); + expect( + within(expiredShortcutRegion).queryByRole('button', { + name: /邀请好友/u, + }), + ).toBeNull(); expect( within(expiredShortcutRegion).queryByRole('button', { name: /填邀请码/u, }), ).toBeNull(); - await screen.findByText('1 / 1'); + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2); + }); + await act(async () => { + await Promise.resolve(); + }); }); test('invite query opens login modal for logged out users', async () => { @@ -2303,6 +2331,22 @@ test('profile redeem invite query modal submits code after login', async () => { expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull(); }); +test('profile task center reloads when refresh key changes', async () => { + const { rerender } = renderProfileView(); + + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2); + }); +}); + test('opens reward code modal from profile action on mobile', async () => { const user = userEvent.setup(); @@ -2330,8 +2374,8 @@ test('profile page shows legal entries and hides archive shortcuts', async () => ?.classList.contains('platform-profile-shortcut-grid'), ).toBe(true); expect( - within(shortcutRegion).getByRole('button', { name: /邀请好友/u }), - ).toBeTruthy(); + within(shortcutRegion).queryByRole('button', { name: /邀请好友/u }), + ).toBeNull(); expect( within(shortcutRegion).getByRole('button', { name: /玩家社区/u }), ).toBeTruthy(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 851e1ca1..aba29612 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -29,7 +29,6 @@ import { Star, ThumbsUp, Ticket, - UserPlus, UserRound, XCircle, } from 'lucide-react'; @@ -50,7 +49,6 @@ import profileClockImage from '../../../media/profile/_Image (1).png'; import profileGamepadImage from '../../../media/profile/_Image (2).png'; import profileStillLifeImage from '../../../media/profile/_Image (3).png'; import profileCoinsImage from '../../../media/profile/_Image (4).png'; -import profileInviteImage from '../../../media/profile/_Image (5).png'; import profileGiftImage from '../../../media/profile/_Image (6).png'; import profileCommunityImage from '../../../media/profile/_Image (7).png'; import profileFeedbackImage from '../../../media/profile/_Image (8).png'; @@ -4026,6 +4024,7 @@ export function RpgEntryHomeView({ useState(null); const [taskCenterError, setTaskCenterError] = useState(null); const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false); + const taskCenterRequestIdRef = useRef(0); const [claimingTaskId, setClaimingTaskId] = useState(null); const [taskClaimSuccess, setTaskClaimSuccess] = useState(null); const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); @@ -4045,6 +4044,7 @@ export function RpgEntryHomeView({ : readProfileInviteCodeFromLocationSearch(window.location.search), [], ); + const promptedLoginForInviteQueryRef = useRef(false); const autoOpenedInviteQueryRef = useRef(false); const [referralRedeemCode, setReferralRedeemCode] = useState( pendingProfileInviteCode, @@ -4375,12 +4375,15 @@ export function RpgEntryHomeView({ return; } - autoOpenedInviteQueryRef.current = true; if (!authUi?.user) { - authUi?.openLoginModal(); + if (!promptedLoginForInviteQueryRef.current) { + promptedLoginForInviteQueryRef.current = true; + authUi?.openLoginModal(); + } return; } + autoOpenedInviteQueryRef.current = true; setReferralRedeemCode(pendingProfileInviteCode); setReferralError(null); setReferralSuccess(null); @@ -4779,21 +4782,34 @@ export function RpgEntryHomeView({ }; }, [handleWechatPayResult]); const loadTaskCenter = useCallback(() => { + const requestId = ++taskCenterRequestIdRef.current; setTaskCenterError(null); setIsLoadingTaskCenter(true); void getRpgProfileTasks() - .then(setTaskCenter) + .then((center) => { + if (requestId === taskCenterRequestIdRef.current) { + setTaskCenter(center); + } + }) .catch((error: unknown) => { + if (requestId !== taskCenterRequestIdRef.current) { + return; + } setTaskCenter(null); setTaskCenterError( error instanceof Error ? error.message : '读取每日任务失败', ); }) - .finally(() => setIsLoadingTaskCenter(false)); + .finally(() => { + if (requestId === taskCenterRequestIdRef.current) { + setIsLoadingTaskCenter(false); + } + }); }, []); useEffect(() => { if (activeTab !== 'profile' || !isAuthenticated) { + taskCenterRequestIdRef.current += 1; setTaskCenter(null); setTaskCenterError(null); return; @@ -6243,13 +6259,6 @@ export function RpgEntryHomeView({ imageSrc={profileCoinsImage} onClick={openRechargeOrRewardCodeModal} /> - openProfilePopupPanel('invite')} - />