Merge pull request 'codex/frontend-error-dialogs' (#40) from codex/frontend-error-dialogs into master

Reviewed-on: #40
This commit was merged in pull request #40.
This commit is contained in:
2026-05-26 22:28:57 +08:00
119 changed files with 8912 additions and 6588 deletions

View File

@@ -16,6 +16,30 @@
---
## 2026-05-26 平台跨流程错误统一用可复制来源弹窗展示
- 背景:拼图等生成链路可能同时存在多个草稿或游玩实例,页面内裸错误 banner 容易让用户误以为当前正在看的拼图失败,也不方便复制完整错误给开发排查。
- 决策:平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`;弹窗必须带错误来源,例如某个草稿、生成会话、作品详情或游玩实例,并提供复制按钮复制来源与错误内容。页面内旧的裸错误 banner、创作入口 modal 错误、生成页错误徽标等不再重复展示;表单校验和发布确认弹窗里的局部业务错误仍可保留在原弹窗内。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/platform-entry/PlatformErrorDialog.tsx``src/components/CustomWorldGenerationView.tsx``src/components/custom-world-home/CustomWorldCreationHub.tsx``src/components/custom-world-home/CustomWorldCreationStartCard.tsx``src/components/platform-entry/PlatformWorkDetailView.tsx``src/components/platform-entry/PlatformEntryCreationTypeModal.tsx``src/components/puzzle-result/PuzzleResultView.tsx`
- 验证方式:`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx``npm run typecheck``npm run check:encoding` 通过;手测时异步失败应弹出包含“错误来源”和“错误内容”的弹窗,复制按钮应复制完整诊断文本。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-26 生成任务完成在离开生成页后弹独立完成弹窗
- 背景:抓大鹅、拼图等生成任务完成时,用户如果已经离开生成页,草稿页的未读红点不足以表达“这次生成已完成”;但如果用户仍停留在生成页,结果页或试玩页本身就是完成反馈,不需要再叠一个成功提示。
- 决策:平台壳层在 `markDraftReady(..., viewedImmediately=false)` 时额外弹出 `PlatformTaskCompletionDialog`,完成弹窗必须带来源和复制按钮;如果 `viewedImmediately=true`,只保留结果页 / 试玩页本身的完成反馈和草稿未读态,不重复弹窗。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/platform-entry/PlatformTaskCompletionDialog.tsx``src/components/platform-entry/PlatformErrorDialog.test.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
- 验证方式:`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "completed match3d draft"` 通过后,离开生成页再完成的草稿应出现“生成完成”弹窗,且复制内容包含来源与状态。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-26 “我的”页任务卡读后端任务摘要并移除常驻填邀请码入口
- 背景:移动端“我的”页每日任务卡曾硬编码 `0 / 1`,任务领取完成后只刷新弹窗内任务中心,卡片本身不更新;页面底部还保留旧的“填邀请码”次级按钮,和当前五项常用功能宫格口径重复。
- 决策:`RpgEntryHomeView` 的每日任务卡以 `/api/profile/tasks` 返回的任务中心为事实源,展示当前可操作任务的奖励、进度和状态;领取成功后同步使用 claim 响应里的 `center` 刷新卡片。移动端“我的”页不再渲染常驻“填邀请码”次级入口,邀请码填写仅保留邀请链接 query 自动打开弹窗和其它明确引导。
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``docs/【项目基线】当前产品与工程约束-2026-05-15.md`
- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应断言任务卡显示 `1 / 1`、领取后显示已完成,且新用户账号也没有 `次级入口` / `填邀请码` 常驻按钮;`npm run typecheck``npm run check:encoding` 通过。
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`
## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态
- 背景:本轮抓大鹅资源管线已经生成完整 `level-scene`、背景、UI spritesheet、物品 spritesheet 和切片资源,需要放入发现页作为可试玩验证入口,但不应把一次性本地资源包装成后端正式作品。
@@ -144,6 +168,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但服务端抠图后处理在玉米等主题上误伤过主体像素。
@@ -172,11 +204,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-audioHyper3D 继续保持薄代理
- 背景:`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 过大。

View File

@@ -15,6 +15,30 @@
- 关联:相关文件、文档、提交或 Issue
```
## 平台异步错误必须带来源弹窗,不要只显示裸错误
- 现象:用户先后触发多个拼图或草稿生成时,旧请求失败后会在当前页面显示“图片生成失败”等裸错误,容易误判为当前正在看的拼图失败;错误文本也不便复制给开发排查。
- 原因:不同入口、生成页、结果页、作品详情和运行态各自渲染局部错误,没有统一携带草稿、生成会话、作品或游玩来源。
- 处理:跨流程错误统一由 `PlatformEntryFlowShellImpl` 汇总为 `PlatformErrorDialog`,来源使用玩法、草稿 / session / work / run 标识组成;弹窗提供复制按钮。关闭弹窗时只清理可安全清理的错误状态;恢复类错误用 dismiss key 防止反复弹出但不擅自改底层状态。
- 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/platform-entry/PlatformErrorDialog.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## “我的”页每日任务卡不要硬编码进度
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗。
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`,领取后显示已完成。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``docs/【项目基线】当前产品与工程约束-2026-05-15.md`
## “我的”页不要恢复旧的填邀请码次级按钮
- 现象:移动端“我的”页在五项常用功能和设置入口下方又出现一个“填邀请码”按钮,看起来像旧入口残留。
- 原因:邀请码流程迁移后仍按新用户窗口保留 `canShowReferralRedeemShortcut` 次级入口;但当前页面口径已经固定为五项常用功能宫格,邀请码填写应由邀请链接 query 或明确引导打开弹窗。
- 处理:移除常驻 `次级入口` / `填邀请码` 渲染,不删除 `ProfileReferralModal``redeem` 面板,也不破坏 `?inviteCode=` / `?invite_code=` 自动打开填写弹窗。
- 验证:新用户账号打开“我的”页时没有 `次级入口``填邀请码` 按钮;带 `?inviteCode=spring-2026` 的登录用户仍自动打开邀请码弹窗并预填 `SPRING2026`
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``.hermes/skills/genarrative-profile-invite-flow/SKILL.md`
## 创作卡片点击要直达已有入口表单,别再保留空白入口页
- 现象:创作 Tab 模板卡点击后如果仍然停留在创作大厅或者先进入“X 创作入口”这种空白页,就会让用户多走一层,还可能被错误的 stage 白名单拉回平台。
@@ -126,6 +150,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 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。
@@ -293,9 +325,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

View File

@@ -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 不变 |

View File

@@ -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 容器 UIVectorEngine `/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 outboxoutbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。

View File

@@ -12,6 +12,10 @@
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`
`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recent` / `最近创作`,避免旧数据、局部 mock 或异常返回把创作入口初始化直接打崩。
@@ -30,7 +34,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` 和汪汪声浪生成页都必须遵循这一口径。
@@ -45,7 +49,7 @@
7. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源不能让结果页返回动作固定跳到创作入口。
8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、次级入口带和法律信息组织,字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
## RPG / 自定义世界
@@ -73,7 +77,7 @@ RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口
RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization持久写入 `story_continue_adventure``deferredOptions``deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset``currentSceneActState``currentEncounter``runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。
RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列表为真相,恢复动作继续走对应恢复接口,但移动端“我的”页已经不再提供独立的 `次级入口 > 存档` 和设置入口存档按钮;“玩过”弹窗可以继续合并展示可继续存档,个人中心只保留设置、扫码常用功能和条件性次级入口。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。
RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列表为真相,恢复动作继续走对应恢复接口,但移动端“我的”页已经不再提供独立的 `次级入口 > 存档` 和设置入口存档按钮;“玩过”弹窗可以继续合并展示可继续存档,个人中心只保留设置、扫码和五项常用功能。移动端“我的”页的五项常用功能宫格只放泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,避免把存档或填邀请码挤入主宫格破坏参考图布局。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。
## 拼图

View File

@@ -95,11 +95,12 @@ server-rs + Axum + SpacetimeDB
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal至少支持玩法类型过滤与排序切换筛选结果为空时显示空状态不把筛选内容展开在当前列表下方。
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口、可选次级入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,填邀请码仅在新用户可填写窗口内展示为次级入口
11. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px``14px` 的普通 UI 字号区间,避免展示级字号挤压内容
12. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能、可选次级入口和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠
13. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗
14. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css``--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮
11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数、进度和领取 / 去完成 / 已完成状态;任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表
12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px``14px` 的普通 UI 字号区间,避免展示级字号挤压内容
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠
14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗
15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css``--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。
## 文案与编码

27
server-rs/Cargo.lock generated
View File

@@ -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",

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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};

View File

@@ -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, "画廊作者");

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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(),

View File

@@ -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))

View File

@@ -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,
};

View File

@@ -55,17 +55,18 @@ use spacetime_client::{
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord,
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput,
PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput,
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;

View File

@@ -606,6 +606,36 @@ pub async fn execute_puzzle_agent_action(
),
"拼图 Agent action 开始执行"
);
let mark_puzzle_compile_failure = |error: &AppError, compile_session_id: &str| {
let state = state.clone();
let owner_user_id = owner_user_id.clone();
let error_message = error.body_text();
let session_id = compile_session_id.to_string();
let log_session_id = session_id.clone();
let log_owner_user_id = owner_user_id.clone();
async move {
let result = state
.spacetime_client()
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
session_id,
owner_user_id,
error_message,
failed_at_micros: now,
})
.await;
if let Err(error) = result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %log_session_id,
owner_user_id = %log_owner_user_id,
message = %error,
"拼图草稿失败态回写失败,继续返回原始错误"
);
}
}
};
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let ai_redraw = payload.ai_redraw.unwrap_or(true);
@@ -666,10 +696,18 @@ pub async fn execute_puzzle_agent_action(
now,
)
.await
};
let session = match session {
Ok(session) => Ok(session),
Err(error) => {
mark_puzzle_compile_failure(&error, &compile_session_id).await;
Err(puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
error,
))
}
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
};
(
"compile_puzzle_draft",
"首关拼图草稿",

View File

@@ -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)

View File

@@ -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()))
}

View File

@@ -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))
})
}

View File

@@ -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()),
})
}

View File

@@ -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)))
}

View File

@@ -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,
})
}

View File

@@ -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,
}
}

View File

@@ -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),
})
}

View File

@@ -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(),
}))
}

View File

@@ -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,
})
}

View File

@@ -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);
}

View File

@@ -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,
}
}
}

View File

@@ -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],

View File

@@ -388,6 +388,22 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft
draft
}
pub fn mark_failed_puzzle_result_draft_generation(
mut draft: PuzzleResultDraft,
) -> PuzzleResultDraft {
if draft.levels.is_empty() {
draft = normalize_puzzle_draft(draft);
}
for level in &mut draft.levels {
if level.generation_status.trim() != "ready" {
level.generation_status = "failed".to_string();
}
}
sync_primary_level_fields(&mut draft);
draft.generation_status = "failed".to_string();
draft
}
pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) {
if let Some(primary_level) = draft.levels.first() {
draft.level_name = primary_level.level_name.clone();
@@ -3224,6 +3240,50 @@ mod tests {
);
}
#[test]
fn failed_generation_marks_pending_levels_failed_without_touching_ready_assets() {
let anchor_pack = infer_anchor_pack("雨夜猫街", Some("雨夜猫街"));
let mut draft = compile_result_draft(&anchor_pack, &[]);
draft.generation_status = "generating".to_string();
draft.levels[0].generation_status = "generating".to_string();
draft.levels.push(PuzzleDraftLevel {
level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_background_image_object_key: None,
background_music: None,
candidates: vec![PuzzleGeneratedImageCandidate {
candidate_id: "candidate-1".to_string(),
image_src: "/ready.png".to_string(),
asset_id: "asset-1".to_string(),
prompt: "prompt".to_string(),
actual_prompt: None,
source_type: "generated".to_string(),
selected: true,
}],
selected_candidate_id: Some("candidate-1".to_string()),
cover_image_src: Some("/ready.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
generation_status: "ready".to_string(),
});
let failed = mark_failed_puzzle_result_draft_generation(draft);
assert_eq!(failed.generation_status, "failed");
assert_eq!(failed.levels[0].generation_status, "failed");
assert_eq!(failed.levels[1].generation_status, "ready");
assert_eq!(failed.levels[1].cover_image_src.as_deref(), Some("/ready.png"));
}
#[test]
fn form_seed_keeps_multiline_picture_description() {
let anchor_pack = infer_anchor_pack(

View File

@@ -68,6 +68,15 @@ pub struct PuzzleDraftCompileInput {
pub compiled_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleDraftCompileFailureInput {
pub session_id: String,
pub owner_user_id: String,
pub error_message: String,
pub failed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleGeneratedImagesSaveInput {

View File

@@ -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]

View 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 }

View 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()
}

View 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()
}

View 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 {}

View 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,
};

View 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()
}
}

View 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)
}

View 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")
}

View 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;

View 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 超过"));
}

View File

@@ -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]

View 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"] }

View 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,
})
}

View 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 {}

View 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};

View 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,
};

View File

@@ -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())
}

View 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))
}

View 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)
}

View 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());
}

View 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;

View 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);
}
}
_ => {}
}
}

View 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);
}
}
_ => {}
}
}

View 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"),
)
}

View 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
}

View 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"
);
}

View 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()
}

View 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;

View 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
);
}

View File

@@ -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 }

View File

@@ -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,
))
}

View File

@@ -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))
}

View File

@@ -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 {}

View File

@@ -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,
};

View File

@@ -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()
}
}

View File

@@ -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、边框、网格线、标签、底座、场景或其他物体。"
))
}

View File

@@ -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,
))
}

View File

@@ -0,0 +1,2 @@
pub mod adapter;
pub mod helpers;

View File

@@ -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() {

View File

@@ -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

View 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)
}

View 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
}

View File

@@ -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";

View 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,
}

View File

@@ -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,
}
}

View 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};

View 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()
}

View 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}")
}

View 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),
})
}

View 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()]
);
}
}

View File

@@ -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),
}
}

View 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,
}

View 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,
}

View 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"))
);
}

View 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"
);
}

View File

@@ -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(),

View File

@@ -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;

View File

@@ -52,7 +52,8 @@ pub use mapper::{
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
PuzzleFormDraftRecord,
PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,

View File

@@ -101,7 +101,8 @@ pub use self::puzzle::{
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
PuzzleFormDraftRecord,
PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,

View File

@@ -636,6 +636,14 @@ pub struct PuzzleAgentMessageFinalizeRecordInput {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleDraftCompileFailureRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub error_message: String,
pub failed_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleGeneratedImagesSaveRecordInput {
pub session_id: String,

View File

@@ -468,6 +468,7 @@ pub mod list_visual_novel_runtime_history_procedure;
pub mod list_visual_novel_works_procedure;
pub mod list_wooden_fish_works_procedure;
pub mod mark_profile_recharge_order_paid_and_return_procedure;
pub mod mark_puzzle_draft_generation_failed_procedure;
pub mod match_3_d_agent_message_finalize_input_type;
pub mod match_3_d_agent_message_row_type;
pub mod match_3_d_agent_message_snapshot_type;
@@ -601,6 +602,7 @@ pub mod puzzle_audio_asset_type;
pub mod puzzle_board_snapshot_type;
pub mod puzzle_cell_position_type;
pub mod puzzle_creator_intent_type;
pub mod puzzle_draft_compile_failure_input_type;
pub mod puzzle_draft_compile_input_type;
pub mod puzzle_draft_level_type;
pub mod puzzle_event_kind_type;
@@ -1501,6 +1503,7 @@ pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_h
pub use list_visual_novel_works_procedure::list_visual_novel_works;
pub use list_wooden_fish_works_procedure::list_wooden_fish_works;
pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return;
pub use mark_puzzle_draft_generation_failed_procedure::mark_puzzle_draft_generation_failed;
pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput;
pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow;
pub use match_3_d_agent_message_snapshot_type::Match3DAgentMessageSnapshot;
@@ -1634,6 +1637,7 @@ pub use puzzle_audio_asset_type::PuzzleAudioAsset;
pub use puzzle_board_snapshot_type::PuzzleBoardSnapshot;
pub use puzzle_cell_position_type::PuzzleCellPosition;
pub use puzzle_creator_intent_type::PuzzleCreatorIntent;
pub use puzzle_draft_compile_failure_input_type::PuzzleDraftCompileFailureInput;
pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput;
pub use puzzle_draft_level_type::PuzzleDraftLevel;
pub use puzzle_event_kind_type::PuzzleEventKind;

Some files were not shown because too many files have changed in this diff Show More