refactor: extract platform media crates
This commit is contained in:
@@ -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 过大。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 不变 |
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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` 和汪汪声浪生成页都必须遵循这一口径。
|
||||
|
||||
|
||||
27
server-rs/Cargo.lock
generated
27
server-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -1792,8 +1792,7 @@ mod tests {
|
||||
"publishedAtMicros": 1_713_686_401_234_000i64
|
||||
});
|
||||
|
||||
let work =
|
||||
map_work_summary_record(gallery_row, &request_context, "画廊作者".to_string())
|
||||
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, "画廊作者");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,7 +429,11 @@ 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(|| {
|
||||
let character_image = character_generated
|
||||
.images
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
jump_hop_error_response(
|
||||
request_context,
|
||||
JUMP_HOP_CREATION_PROVIDER,
|
||||
@@ -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,7 +566,8 @@ async fn persist_jump_hop_generated_image_asset(
|
||||
request_context: &RequestContext,
|
||||
) -> Result<JumpHopCharacterAsset, Response> {
|
||||
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
|
||||
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
let prepared =
|
||||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix,
|
||||
path_segments: vec![profile_id.to_string(), slot.to_string()],
|
||||
file_stem: "image".to_string(),
|
||||
@@ -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(),
|
||||
|
||||
@@ -65,7 +65,10 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
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))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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!({
|
||||
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,7 +650,8 @@ 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!({
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
@@ -657,7 +660,8 @@ pub(crate) fn validate_puzzle_reference_asset_object(
|
||||
),
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})));
|
||||
})),
|
||||
);
|
||||
}
|
||||
if let Some(expected_owner_user_id) = owner_user_id
|
||||
.map(str::trim)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()))
|
||||
}
|
||||
@@ -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<String, AppError> {
|
||||
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<String> {
|
||||
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<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn parse_json_payload<T>(
|
||||
request_context: &RequestContext,
|
||||
payload: Result<Json<T>, JsonRejection>,
|
||||
) -> Result<Json<T>, Response> {
|
||||
payload.map_err(|rejection| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message(format!("请求体 JSON 不合法:{rejection}"))
|
||||
.into_response_with_context(Some(request_context))
|
||||
})
|
||||
}
|
||||
@@ -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<u8>,
|
||||
seed: Option<u64>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
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<String>,
|
||||
model: Option<String>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
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()),
|
||||
})
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<contract::CreateVisualNovelBackgroundMusicRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateBackgroundMusicRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<contract::CreateVisualNovelSoundEffectRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateSoundEffectRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(_task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, 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)))
|
||||
}
|
||||
@@ -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<PersistedAudioAsset, AppError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
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<String>) =
|
||||
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<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<VectorEngineAudioSettings, AppError> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -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<AudioAssetBindingTarget, AppError> {
|
||||
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<AudioAssetBindingTarget, AppError> {
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
@@ -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<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
|
||||
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<u8>,
|
||||
seed: Option<u64>,
|
||||
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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]
|
||||
|
||||
13
server-rs/crates/platform-audio/Cargo.toml
Normal file
13
server-rs/crates/platform-audio/Cargo.toml
Normal file
@@ -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 }
|
||||
255
server-rs/crates/platform-audio/src/client.rs
Normal file
255
server-rs/crates/platform-audio/src/client.rs
Normal file
@@ -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, AudioError> {
|
||||
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<AudioTaskResponse, AudioError> {
|
||||
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<AudioTaskResponse, AudioError> {
|
||||
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<Value, AudioError> {
|
||||
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<String>), 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<Value, AudioError> {
|
||||
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<Value, AudioError> {
|
||||
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<Value, AudioError> {
|
||||
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::<Value>(&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()
|
||||
}
|
||||
118
server-rs/crates/platform-audio/src/download.rs
Normal file
118
server-rs/crates/platform-audio/src/download.rs
Normal file
@@ -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<DownloadedAudio, AudioError> {
|
||||
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()
|
||||
}
|
||||
167
server-rs/crates/platform-audio/src/error.rs
Normal file
167
server-rs/crates/platform-audio/src/error.rs
Normal file
@@ -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<String>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
request: bool,
|
||||
body: bool,
|
||||
status_code: Option<u16>,
|
||||
source: Option<String>,
|
||||
},
|
||||
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<String>) -> Self {
|
||||
Self::InvalidConfig {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid_request(message: impl Into<String>) -> Self {
|
||||
Self::InvalidRequest {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request(
|
||||
message: impl Into<String>,
|
||||
endpoint: Option<String>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
request: bool,
|
||||
body: bool,
|
||||
status_code: Option<u16>,
|
||||
source: Option<String>,
|
||||
) -> Self {
|
||||
Self::Request {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
endpoint,
|
||||
timeout,
|
||||
connect,
|
||||
request,
|
||||
body,
|
||||
status_code,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upstream(
|
||||
message: impl Into<String>,
|
||||
upstream_status: u16,
|
||||
raw_excerpt: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::Upstream {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
upstream_status,
|
||||
raw_excerpt: raw_excerpt.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn response_parse(message: impl Into<String>, raw_excerpt: impl Into<String>) -> Self {
|
||||
Self::ResponseParse {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: message.into(),
|
||||
raw_excerpt: raw_excerpt.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn missing_audio(message: impl Into<String>) -> 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 {}
|
||||
32
server-rs/crates/platform-audio/src/lib.rs
Normal file
32
server-rs/crates/platform-audio/src/lib.rs
Normal file
@@ -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,
|
||||
};
|
||||
106
server-rs/crates/platform-audio/src/persist.rs
Normal file
106
server-rs/crates/platform-audio/src/persist.rs
Normal file
@@ -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<String>,
|
||||
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<String, String> {
|
||||
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::<String>();
|
||||
let collapsed = normalized
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if collapsed.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
collapsed.chars().take(80).collect()
|
||||
}
|
||||
}
|
||||
94
server-rs/crates/platform-audio/src/request.rs
Normal file
94
server-rs/crates/platform-audio/src/request.rs
Normal file
@@ -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<Value, AudioError> {
|
||||
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<Value, AudioError> {
|
||||
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<String, AudioError> {
|
||||
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<String, AudioError> {
|
||||
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<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
125
server-rs/crates/platform-audio/src/response.rs
Normal file
125
server-rs/crates/platform-audio/src/response.rs
Normal file
@@ -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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String>) {
|
||||
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")
|
||||
}
|
||||
87
server-rs/crates/platform-audio/src/types.rs
Normal file
87
server-rs/crates/platform-audio/src/types.rs
Normal file
@@ -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<String>,
|
||||
pub model: Option<String>,
|
||||
pub instrumental: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SoundEffectTaskRequest {
|
||||
pub prompt: String,
|
||||
pub duration: u8,
|
||||
pub seed: Option<u64>,
|
||||
}
|
||||
|
||||
#[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<u8>,
|
||||
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;
|
||||
84
server-rs/crates/platform-audio/tests/vector_engine_audio.rs
Normal file
84
server-rs/crates/platform-audio/tests/vector_engine_audio.rs
Normal file
@@ -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 超过"));
|
||||
}
|
||||
@@ -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<RuntimeGuestTokenClaims, JwtError> {
|
||||
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]
|
||||
|
||||
14
server-rs/crates/platform-hyper3d/Cargo.toml
Normal file
14
server-rs/crates/platform-hyper3d/Cargo.toml
Normal file
@@ -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"] }
|
||||
179
server-rs/crates/platform-hyper3d/src/client.rs
Normal file
179
server-rs/crates/platform-hyper3d/src/client.rs
Normal file
@@ -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, Hyper3dError> {
|
||||
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<contract::Hyper3dTaskSubmitResponse, Hyper3dError> {
|
||||
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<contract::Hyper3dTaskSubmitResponse, Hyper3dError> {
|
||||
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<contract::Hyper3dTaskStatusResponse, Hyper3dError> {
|
||||
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<contract::Hyper3dDownloadResponse, Hyper3dError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
180
server-rs/crates/platform-hyper3d/src/error.rs
Normal file
180
server-rs/crates/platform-hyper3d/src/error.rs
Normal file
@@ -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<Vec<String>>,
|
||||
},
|
||||
Request {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
endpoint: Option<String>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
request: bool,
|
||||
body: bool,
|
||||
status_code: Option<u16>,
|
||||
source: Option<String>,
|
||||
},
|
||||
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<String>) -> Self {
|
||||
Self::InvalidConfig {
|
||||
provider: HYPER3D_PROVIDER,
|
||||
reason: Some(reason),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn invalid_request(field: Option<&'static str>, message: impl Into<String>) -> Self {
|
||||
Self::InvalidRequest {
|
||||
provider: HYPER3D_PROVIDER,
|
||||
field,
|
||||
message: message.into(),
|
||||
allowed: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn invalid_request_allowed(
|
||||
field: &'static str,
|
||||
message: impl Into<String>,
|
||||
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<String>,
|
||||
endpoint: Option<String>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
request: bool,
|
||||
body: bool,
|
||||
status_code: Option<u16>,
|
||||
source: Option<String>,
|
||||
) -> Self {
|
||||
Self::Request {
|
||||
provider: HYPER3D_PROVIDER,
|
||||
message: message.into(),
|
||||
endpoint,
|
||||
timeout,
|
||||
connect,
|
||||
request,
|
||||
body,
|
||||
status_code,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn upstream(
|
||||
message: impl Into<String>,
|
||||
upstream_status: u16,
|
||||
raw_excerpt: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::Upstream {
|
||||
provider: HYPER3D_PROVIDER,
|
||||
message: message.into(),
|
||||
upstream_status,
|
||||
raw_excerpt: raw_excerpt.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn response_parse(
|
||||
message: impl Into<String>,
|
||||
raw_excerpt: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::ResponseParse {
|
||||
provider: HYPER3D_PROVIDER,
|
||||
message: message.into(),
|
||||
raw_excerpt: raw_excerpt.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn missing_field(message: impl Into<String>) -> 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 {}
|
||||
13
server-rs/crates/platform-hyper3d/src/lib.rs
Normal file
13
server-rs/crates/platform-hyper3d/src/lib.rs
Normal file
@@ -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};
|
||||
14
server-rs/crates/platform-hyper3d/src/request.rs
Normal file
14
server-rs/crates/platform-hyper3d/src/request.rs
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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<Vec<DecodedImageDataUrl>, 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<DecodedImageDataUrl, Hyper3dError> {
|
||||
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())
|
||||
}
|
||||
119
server-rs/crates/platform-hyper3d/src/request/normalize.rs
Normal file
119
server-rs/crates/platform-hyper3d/src/request/normalize.rs
Normal file
@@ -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<String, Hyper3dError> {
|
||||
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<Option<String>, 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<String, Hyper3dError> {
|
||||
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<String, Hyper3dError> {
|
||||
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<String, Hyper3dError> {
|
||||
normalize_enum(
|
||||
value,
|
||||
DEFAULT_CONDITION_MODE,
|
||||
&["concat", "fuse"],
|
||||
"conditionMode",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_addons(values: Vec<String>) -> Result<Vec<String>, 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<Vec<f32>>,
|
||||
) -> Result<Option<Vec<f32>>, 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))
|
||||
}
|
||||
111
server-rs/crates/platform-hyper3d/src/request/options.rs
Normal file
111
server-rs/crates/platform-hyper3d/src/request/options.rs
Normal file
@@ -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, Hyper3dError> {
|
||||
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, Hyper3dError> {
|
||||
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<u32>,
|
||||
geometry_file_format: Option<&str>,
|
||||
material: Option<&str>,
|
||||
quality: Option<&str>,
|
||||
mesh_mode: Option<&str>,
|
||||
addons: Vec<String>,
|
||||
bbox_condition: Option<Vec<f32>>,
|
||||
preview_render: Option<bool>,
|
||||
) -> Result<Self, Hyper3dError> {
|
||||
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<reqwest::multipart::Form, Hyper3dError> {
|
||||
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)
|
||||
}
|
||||
64
server-rs/crates/platform-hyper3d/src/request/tests.rs
Normal file
64
server-rs/crates/platform-hyper3d/src/request/tests.rs
Normal file
@@ -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());
|
||||
}
|
||||
11
server-rs/crates/platform-hyper3d/src/response.rs
Normal file
11
server-rs/crates/platform-hyper3d/src/response.rs
Normal file
@@ -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;
|
||||
67
server-rs/crates/platform-hyper3d/src/response/downloads.rs
Normal file
67
server-rs/crates/platform-hyper3d/src/response/downloads.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) fn extract_download_files(
|
||||
payload: &Value,
|
||||
) -> Vec<shared_contracts::hyper3d::Hyper3dDownloadFilePayload> {
|
||||
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<shared_contracts::hyper3d::Hyper3dDownloadFilePayload>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
159
server-rs/crates/platform-hyper3d/src/response/parsing.rs
Normal file
159
server-rs/crates/platform-hyper3d/src/response/parsing.rs
Normal file
@@ -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::<Value>(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::<String>()
|
||||
.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<Value>> {
|
||||
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<String> {
|
||||
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<f64> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
69
server-rs/crates/platform-hyper3d/src/response/status.rs
Normal file
69
server-rs/crates/platform-hyper3d/src/response/status.rs
Normal file
@@ -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<shared_contracts::hyper3d::Hyper3dJobStatusPayload> {
|
||||
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"),
|
||||
)
|
||||
}
|
||||
64
server-rs/crates/platform-hyper3d/src/response/submit.rs
Normal file
64
server-rs/crates/platform-hyper3d/src/response/submit.rs
Normal file
@@ -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<shared_contracts::hyper3d::Hyper3dTaskSubmitResponse, Hyper3dError> {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
88
server-rs/crates/platform-hyper3d/src/response/tests.rs
Normal file
88
server-rs/crates/platform-hyper3d/src/response/tests.rs
Normal file
@@ -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"
|
||||
);
|
||||
}
|
||||
99
server-rs/crates/platform-hyper3d/src/transport.rs
Normal file
99
server-rs/crates/platform-hyper3d/src/transport.rs
Normal file
@@ -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<Value, Hyper3dError> {
|
||||
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<Value, Hyper3dError> {
|
||||
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<Value, Hyper3dError> {
|
||||
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::<Value>(&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()
|
||||
}
|
||||
37
server-rs/crates/platform-hyper3d/src/types.rs
Normal file
37
server-rs/crates/platform-hyper3d/src/types.rs
Normal file
@@ -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<u8>,
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) file_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SubmitOptions {
|
||||
pub(crate) seed: Option<u32>,
|
||||
pub(crate) geometry_file_format: String,
|
||||
pub(crate) material: String,
|
||||
pub(crate) quality: String,
|
||||
pub(crate) mesh_mode: String,
|
||||
pub(crate) addons: Vec<String>,
|
||||
pub(crate) bbox_condition: Option<Vec<f32>>,
|
||||
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;
|
||||
61
server-rs/crates/platform-hyper3d/tests/hyper3d_rodin.rs
Normal file
61
server-rs/crates/platform-hyper3d/tests/hyper3d_rodin.rs
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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::<usize>::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<usize>| {
|
||||
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,
|
||||
))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<String>) -> Self {
|
||||
Self::InvalidRequest {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_image(message: impl Into<String>) -> Self {
|
||||
Self::DecodeImage {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_image(message: impl Into<String>) -> Self {
|
||||
Self::EncodeImage {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_http_client(message: impl Into<String>) -> 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 {}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<String>,
|
||||
pub item_name_prompt: Option<String>,
|
||||
pub special_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub file_name: String,
|
||||
pub content_type: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub asset_kind: String,
|
||||
pub source_job_id: Option<String>,
|
||||
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<OssPutObjectRequest, GeneratedAssetSheetError> {
|
||||
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<GeneratedAssetSheetUpload, GeneratedAssetSheetError> {
|
||||
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<String, String>,
|
||||
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::<String>();
|
||||
let collapsed = normalized
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if collapsed.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
collapsed.chars().take(64).collect()
|
||||
}
|
||||
}
|
||||
@@ -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<String, GeneratedAssetSheetError> {
|
||||
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::<Vec<_>>()
|
||||
.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、边框、网格线、标签、底座、场景或其他物体。"
|
||||
))
|
||||
}
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
pub fn slice_generated_asset_sheet(
|
||||
image: &crate::DownloadedImage,
|
||||
item_names: &[String],
|
||||
grid_size: usize,
|
||||
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, 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<Vec<Vec<GeneratedAssetSheetSliceImage>>, 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<GeneratedAssetSheetCellBounds> {
|
||||
let background = sample_generated_asset_sheet_cell_background(source, cell);
|
||||
let mut foreground: Option<GeneratedAssetSheetCellBounds> = 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<GeneratedAssetSheetCellBounds> {
|
||||
let (width, height) = image.dimensions();
|
||||
let mut bounds: Option<GeneratedAssetSheetCellBounds> = 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::<usize>::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,
|
||||
))
|
||||
}
|
||||
2
server-rs/crates/platform-image/src/generated_assets.rs
Normal file
2
server-rs/crates/platform-image/src/generated_assets.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod adapter;
|
||||
pub mod helpers;
|
||||
@@ -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<String>,
|
||||
pub(crate) file_stem: String,
|
||||
pub(crate) image: GeneratedImageAssetDataUrl,
|
||||
pub(crate) access: OssObjectAccess,
|
||||
pub(crate) metadata: GeneratedImageAssetAdapterMetadata,
|
||||
pub(crate) extra_metadata: BTreeMap<String, String>,
|
||||
pub struct GeneratedImageAssetPersistInput {
|
||||
pub prefix: LegacyAssetPrefix,
|
||||
pub path_segments: Vec<String>,
|
||||
pub file_stem: String,
|
||||
pub image: GeneratedImageAssetDataUrl,
|
||||
pub access: OssObjectAccess,
|
||||
pub metadata: GeneratedImageAssetAdapterMetadata,
|
||||
pub extra_metadata: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetAdapterMetadata {
|
||||
pub(crate) asset_kind: Option<String>,
|
||||
pub(crate) owner_user_id: Option<String>,
|
||||
pub(crate) entity_kind: Option<String>,
|
||||
pub(crate) entity_id: Option<String>,
|
||||
pub(crate) slot: Option<String>,
|
||||
pub(crate) provider: Option<String>,
|
||||
pub(crate) task_id: Option<String>,
|
||||
pub struct GeneratedImageAssetAdapterMetadata {
|
||||
pub asset_kind: Option<String>,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub entity_kind: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
pub slot: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub task_id: Option<String>,
|
||||
}
|
||||
|
||||
#[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<GeneratedImageAssetPreparedPut, GeneratedImageAssetHelperError> {
|
||||
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() {
|
||||
@@ -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<u8>,
|
||||
pub struct GeneratedImageAssetDataUrl {
|
||||
pub format: GeneratedImageAssetImageFormat,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct GeneratedImageAssetMetadataInput {
|
||||
pub(crate) asset_kind: Option<String>,
|
||||
pub(crate) owner_user_id: Option<String>,
|
||||
pub(crate) entity_kind: Option<String>,
|
||||
pub(crate) entity_id: Option<String>,
|
||||
pub(crate) slot: Option<String>,
|
||||
pub(crate) provider: Option<String>,
|
||||
pub(crate) task_id: Option<String>,
|
||||
pub struct GeneratedImageAssetMetadataInput {
|
||||
pub asset_kind: Option<String>,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub entity_kind: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
pub slot: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub task_id: Option<String>,
|
||||
}
|
||||
|
||||
#[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<str>,
|
||||
) -> 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<GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError> {
|
||||
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<String, String> {
|
||||
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<String, String>,
|
||||
overlay: BTreeMap<String, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
64
server-rs/crates/platform-image/src/vector_engine/audit.rs
Normal file
64
server-rs/crates/platform-image/src/vector_engine/audit.rs
Normal file
@@ -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<u16>,
|
||||
pub status_class: Option<&'static str>,
|
||||
pub timeout: bool,
|
||||
pub retryable: bool,
|
||||
pub error_message: String,
|
||||
pub error_source: Option<String>,
|
||||
pub raw_excerpt: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub prompt_chars: Option<usize>,
|
||||
pub reference_image_count: Option<usize>,
|
||||
pub image_model: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub(crate) fn build_failure_audit(
|
||||
request_url: &str,
|
||||
operation: &str,
|
||||
failure_stage: &'static str,
|
||||
status_code: Option<u16>,
|
||||
status_class: Option<&'static str>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
error_message: &str,
|
||||
error_source: Option<String>,
|
||||
raw_excerpt: Option<String>,
|
||||
latency_ms: Option<u64>,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
) -> 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<u16>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
) -> bool {
|
||||
timeout
|
||||
|| connect
|
||||
|| status_code.is_some_and(|status| status == 429 || status == 408 || status >= 500)
|
||||
}
|
||||
245
server-rs/crates/platform-image/src/vector_engine/client.rs
Normal file
245
server-rs/crates/platform-image/src/vector_engine/client.rs
Normal file
@@ -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<GeneratedImages, PlatformImageError> {
|
||||
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<GeneratedImages, PlatformImageError> {
|
||||
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<GeneratedImages, PlatformImageError> {
|
||||
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
|
||||
}
|
||||
@@ -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";
|
||||
114
server-rs/crates/platform-image/src/vector_engine/error.rs
Normal file
114
server-rs/crates/platform-image/src/vector_engine/error.rs
Normal file
@@ -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<String>,
|
||||
timeout: bool,
|
||||
connect: bool,
|
||||
request: bool,
|
||||
body: bool,
|
||||
status_code: Option<u16>,
|
||||
source: Option<String>,
|
||||
audit: Option<PlatformImageFailureAudit>,
|
||||
},
|
||||
Upstream {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
upstream_status: u16,
|
||||
raw_excerpt: String,
|
||||
audit: Option<PlatformImageFailureAudit>,
|
||||
},
|
||||
ResponseParse {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
raw_excerpt: String,
|
||||
audit: Option<PlatformImageFailureAudit>,
|
||||
},
|
||||
MissingImage {
|
||||
provider: &'static str,
|
||||
message: String,
|
||||
audit: Option<PlatformImageFailureAudit>,
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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<DownloadedImage, PlatformImageError> {
|
||||
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<String>,
|
||||
candidate_count: u32,
|
||||
) -> Result<GeneratedImages, PlatformImageError> {
|
||||
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<Vec<ReferenceImage>, 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<Option<ReferenceImage>, 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<String>,
|
||||
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<DownloadedImage> {
|
||||
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<String>) -> PlatformImageError {
|
||||
PlatformImageError::Request {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message,
|
||||
endpoint,
|
||||
timeout: false,
|
||||
connect: false,
|
||||
request: true,
|
||||
body: false,
|
||||
status_code: None,
|
||||
source: None,
|
||||
audit: None,
|
||||
}
|
||||
}
|
||||
26
server-rs/crates/platform-image/src/vector_engine/mod.rs
Normal file
26
server-rs/crates/platform-image/src/vector_engine/mod.rs
Normal file
@@ -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};
|
||||
128
server-rs/crates/platform-image/src/vector_engine/payload.rs
Normal file
128
server-rs/crates/platform-image/src/vector_engine/payload.rs
Normal file
@@ -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<ParsedJsonPayload, PlatformImageError> {
|
||||
serde_json::from_str::<Value>(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<String>) {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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::<Value>(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()
|
||||
}
|
||||
69
server-rs/crates/platform-image/src/vector_engine/request.rs
Normal file
69
server-rs/crates/platform-image/src/vector_engine/request.rs
Normal file
@@ -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}")
|
||||
}
|
||||
180
server-rs/crates/platform-image/src/vector_engine/response.rs
Normal file
180
server-rs/crates/platform-image/src/vector_engine/response.rs
Normal file
@@ -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<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
candidate_count: u32,
|
||||
task_prefix: &str,
|
||||
) -> Result<GeneratedImages, PlatformImageError> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
177
server-rs/crates/platform-image/src/vector_engine/tests.rs
Normal file
177
server-rs/crates/platform-image/src/vector_engine/tests.rs
Normal file
@@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, PlatformImageError> {
|
||||
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<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
) -> 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),
|
||||
}
|
||||
}
|
||||
27
server-rs/crates/platform-image/src/vector_engine/types.rs
Normal file
27
server-rs/crates/platform-image/src/vector_engine/types.rs
Normal file
@@ -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<String>,
|
||||
pub images: Vec<DownloadedImage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DownloadedImage {
|
||||
pub bytes: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReferenceImage {
|
||||
pub bytes: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
pub file_name: String,
|
||||
}
|
||||
89
server-rs/crates/platform-image/src/vector_engine/util.rs
Normal file
89
server-rs/crates/platform-image/src/vector_engine/util.rs
Normal file
@@ -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,
|
||||
}
|
||||
229
server-rs/crates/platform-image/tests/generated_asset_sheets.rs
Normal file
229
server-rs/crates/platform-image/tests/generated_asset_sheets.rs
Normal file
@@ -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<u8> {
|
||||
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"))
|
||||
);
|
||||
}
|
||||
32
server-rs/crates/platform-image/tests/vector_engine.rs
Normal file
32
server-rs/crates/platform-image/tests/vector_engine.rs
Normal file
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user