合并编辑器素材库分支

合入 codex/editor-asset-library 最新改动
保留音视频 OSS 素材库保存与自定义播放器交互
合并画布素材标签编辑与视频换签渲染逻辑
通过后端编译和角色抠图回归测试
This commit is contained in:
2026-06-19 15:58:20 +08:00
70 changed files with 4418 additions and 273 deletions
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,14 @@
---
## 2026-06-19 外部 OpenAPI 与 API Key 管理走 server-rs 正式链路
- 背景:外部调用方需要稳定调用图片画布项目创建、画布布局保存和编辑器美术生图能力,同时需要可撤销的开发者凭据,不能依赖前端临时状态或人工分发密钥。
- 决策:外部 API 固定放在 `/api/external/v1` 命名空间,v1 暴露项目创建 / 读取、默认画布保存、编辑器美术生图和 `/api/external/v1/openapi.json`。API Key 管理走登录态 `/api/profile/api-keys`,外部调用使用 `Authorization: Bearer tnr_sk_xxx`;后端只保存 `key_hash``key_prefix`,明文只在创建响应返回一次。外部 API 鉴权、项目 / 画布 / 素材写回全部经 `api-server -> spacetime-client -> spacetime-module`,生成图片成功后同时写入账号级 `editor_asset`,带 `projectId` 时写入 `editor_project_resource`
- 影响范围:`server-rs/crates/api-server/src/external_*``server-rs/crates/api-server/src/modules/external_api.rs``server-rs/crates/spacetime-module/src/external_api_key_storage.rs``server-rs/crates/spacetime-client/src/external_api_key.rs``docs/openapi/genarrative-external-v1.openapi.json` 和后端数据契约文档。
- 验证方式:`cargo test -p api-server external_api --manifest-path server-rs/Cargo.toml``cargo test -p api-server external_editor_api --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置
- 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。
@@ -2400,7 +2408,7 @@
## 2026-06-19 编辑器游戏音效默认改用 Vidu 文生音频
- 背景:VectorEngine Apifox `创建文生音频任务` 文档明确 Vidu `/ent/v2/text2audio` 请求体使用 `model: "audio1.0"``prompt``duration` 和可选 `seed`;编辑器此前把游戏音效提交到 Suno `task: "sound"`,与当前游戏音效默认模型要求不一致。
- 决策:`/editor/canvas``生成游戏音效` 入口继续保留,但默认且暂时唯一可用模型为 Vidu `audio1.0`,前端请求固定发送 `prompt``model: "audio1.0"``duration``priceMudPoints`,面板只显示 `Vidu``2-10` 秒时长选项,默认 `5` 秒;不再发送或展示 `sound``type``tempo`、BPM 或 Suno 文生音效入口。后端 `/api/editor/audios/sound-effects/generations` 只接受空模型或 `audio1.0`,拒绝 Suno / `chirp-*`;提交和轮询改走 Vidu `/ent/v2/text2audio``/ent/v2/tasks/{taskId}/creations`。背景音乐仍保留 Suno `/suno/submit/music``/suno/fetch/{taskId}` 和 wav clip 兜底逻辑。
- 决策:`/editor/canvas``生成游戏音效` 入口继续保留,但默认且暂时唯一可用模型为 Vidu `audio1.0`,前端请求固定发送 `prompt``model: "audio1.0"``duration``priceMudPoints`,面板只显示 `Vidu``2-10` 秒时长选项,默认 `5` 秒;不再展示 `type``tempo`、BPM 或 Suno 文生音效入口。后端 `/api/editor/audios/sound-effects/generations` 只接受空模型或 `audio1.0`,拒绝 Suno / `chirp-*`;提交到 VectorEngine 时对内 `prompt` 同步映射为上游 body 的 `prompt``sound`,兼容 Apifox 文档和线上网关实际 `missing field sound` 校验;提交和轮询改走 Vidu `/ent/v2/text2audio``/ent/v2/tasks/{taskId}/creations`。背景音乐仍保留 Suno `/suno/submit/music``/suno/fetch/{taskId}` 和 wav clip 兜底逻辑。
- 影响范围:`server-rs/crates/platform-audio``server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs``server-rs/crates/shared-contracts/src/assets.rs``src/services/image-editor/editorProjectClient.ts``src/components/image-editor/ImageCanvasGeneration*`
- 验证方式:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio``cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_sound_effect``cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_audio_requests_and_response_use_canvas_audio_shape``npx vitest run src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasGenerationModel.test.ts --reporter=dot`
@@ -15,6 +15,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## Vidu 文生音频线上网关可能要求 sound 字段
- 现象:画板点击 `生成游戏音效` 后,请求返回 `Failed to deserialize the JSON body into the target type: missing field sound`
- 原因:VectorEngine Apifox `创建文生音频任务` 文档仍写 `/ent/v2/text2audio` 使用 `model + prompt + duration`,但线上 Vidu 网关曾按 `sound` 字段反序列化;只发送 `prompt` 会被上游拦截在 JSON 解析阶段。
- 处理:前端和 BFF 对内继续使用用户语义更清晰的 `prompt``platform-audio` 转发到 VectorEngine Vidu 时同时发送 `prompt``sound`,两者值保持一致。不要把 UI 改回 Suno `task: "sound"``type``tempo` 或 BPM。
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio` 中音效请求体测试必须同时断言 `prompt``sound`;必要时用线上生成音效 smoke 确认不再出现 `missing field sound`
- 关联:`server-rs/crates/platform-audio/src/request.rs``server-rs/crates/platform-audio/tests/vector_engine_audio.rs``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
## Suno 任务完成不代表已经拿到 wav 下载地址
- 现象:画板生成背景音乐时,前端报 `音频生成尚未返回可下载地址(requestId:...)`;画板生成音效时,前端可能报 `获取 Suno 音效 wav 失败(requestId:...)`。上游任务可能已经完成,但 wav 下载地址还没就绪。
@@ -12,7 +12,7 @@
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;`assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。
- 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。
@@ -429,6 +429,13 @@ npm run check:server-rs-ddd
- Rust 结构体:`DatabaseMigrationOperator`
- 源码:`server-rs/crates/spacetime-module/src/migration.rs`
### `external_api_key`
- Rust 结构体:`ExternalApiKey`
- 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs`
- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB。
- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。
### `editor_project`
- Rust 结构体:`EditorProject`
@@ -0,0 +1,101 @@
# 外部 OpenAPI 与 API Key 接入方案
## 背景
外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的美术生图能力,并能创建项目、保存画板布局。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态或生成结果放到前端临时状态中。
## v1 范围
本期新增外部 API 命名空间:
```text
/api/external/v1
```
v1 只开放以下能力:
- `POST /api/external/v1/editor/projects`:创建图片画布项目。
- `GET /api/external/v1/editor/projects/{projectId}`:读取项目与默认画布。
- `PATCH /api/external/v1/editor/projects/{projectId}/canvas`:保存默认画布的 viewport 和 layers。
- `POST /api/external/v1/editor/images/generations`:调用编辑器美术生图能力;可选传入 `projectId`,生成后自动写入 `editor_project_resource`,同时写入账号级 `editor_asset` 素材库。
- `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。
管理 API Key 的登录态接口:
```text
GET /api/profile/api-keys
POST /api/profile/api-keys
DELETE /api/profile/api-keys/{keyId}
```
前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。
## 鉴权
外部调用使用 Bearer API Key
```http
Authorization: Bearer tnr_sk_xxx
```
规则:
- API Key 归属于 `owner_user_id`,外部接口只能访问该账号自己的项目、画布和生成素材。
- 明文 Key 只在创建接口返回一次,后端只保存 `key_hash``key_prefix`
- API Key 被撤销后立即不可再用于外部接口。
- 外部 API 鉴权不复用登录态 JWT,不检查 refresh session;它是独立开发者凭据。
- OpenAPI JSON 公共可读,不需要鉴权。
## 数据模型
新增 SpacetimeDB private 表:
```text
external_api_key
```
字段:
- `key_id`:主键。
- `owner_user_id`:所属账号。
- `name`:用户可识别名称。
- `key_prefix`:前缀片段,用于列表展示和排障。
- `key_hash`:完整 Key 的 SHA-256 十六进制摘要,唯一。
- `scopes_json`:作用域 JSONv1 固定包含 `editor:project``editor:canvas``editor:image-generate`
- `created_at` / `last_used_at` / `revoked_at` / `updated_at`
SpacetimeDB procedure
- `create_external_api_key_and_return`
- `list_external_api_keys_and_return`
- `revoke_external_api_key_and_return`
- `authenticate_external_api_key_and_return`
## 生成图落库
外部生图接口复用编辑器内 `VectorEngine` / `gpt-image-2` 生成链路,后端拿到图片后:
1. 通过 OSS / asset object adapter 持久化图片。
2. 写入 `editor_asset`,让生成图进入账号级素材库。
3. 如果请求带 `projectId`,写入 `editor_project_resource`
4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model、provider 和 taskId。
如果请求未带 `projectId`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。
## OpenAPI 导出
OpenAPI 3.1 JSON 固定落在:
```text
docs/openapi/genarrative-external-v1.openapi.json
```
服务端 `GET /api/external/v1/openapi.json` 使用同一份 JSON,通过 `include_str!` 导出,避免运行时生成结果与仓库文档漂移。
## 验收
- API Key 创建只返回一次明文,列表不返回明文。
- 撤销后的 API Key 调用外部接口返回 `401`
- 外部生图成功后,生成结果同时出现在画布资源和账号级素材库。
- OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。
- 修改 SpacetimeDB schema 后运行 `npm run spacetime:generate``npm run check:spacetime-schema`
@@ -12,6 +12,8 @@
- `生成图标素材`
- `生成UI设计图`
- `生成视频`
- `生成游戏音效`
- `生成游戏背景音乐`
- `生成角色动作`
- `快速编辑`
@@ -49,8 +51,8 @@
```
- 面板内不显示 `画面比例``大小尺寸``模型` 等字段标题,只显示当前选择值。
- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框;音效面板把 type 和 BPM 合并成一个无标题左下角选项框。
- 模型选项框和生成按钮位于右下角。
- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框;音效面板把 Vidu duration 合并成一个无标题左下角选项框。
- 模型选项框和生成按钮位于右下角,模型胶囊紧贴生成按钮;音效固定显示 `Vidu`,背景音乐固定显示 `Suno`,两者均沿用模型按钮样式但不可点击
- 点击后以页面级浮层向上弹出独立选项面板;父级面板隐藏或销毁时,选项面板同步销毁。
- 点击选项只更新字段和选中样式,不收起选项面板;再次点击当前参数按钮可收起。
- 选项面板打开后,点击父级生成面板内任意非选项框、非触发按钮区域必须收起;点击选项面板自身仍保持打开,便于连续修改。
@@ -58,7 +60,7 @@
- 模型子面板每行固定一个模型,不用方框包裹模型名;模型名不换行,前置对应模型类型图标,选中项在模型名后用对号标记。
- 比例选项卡片内展示对应比例的线框。
- 父级面板、底部选项框、弹出子面板字号保持一致。
- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p``单次·120BPM`
- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p``16:9 · 1K`
- 底部参数热区与生成按钮等高,默认不显示阴影;悬停显示轻量阴影;箭头默认向下,展开后旋转向上。
- 底边栏中会在上方弹出二级选项的入口不再依赖点击展开。鼠标悬停到入口即可打开二级面板,鼠标离开入口和二级面板后自动收起;当前范围包括 `生成规范``生成音乐`
- 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。
@@ -115,12 +117,9 @@
## 第一版计费配置
```text
生成图片12 泥点
生成规范:5 泥点
生成角色形象:12 泥点
生成图标素材:12 泥点
生成UI设计图:12 泥点
生成视频:480p 每秒 10 泥点,720p 每秒 20 泥点
生成图片 / 生成角色形象 / 生成图标素材 / 生成UI设计图 / 宣发素材 / 快速编辑 / 重绘:nanobanana2 为 12 泥点,gpt-image-2 为 20 泥点
生成规范:固定 gpt-image-25 泥点
生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20seedance2.0 为 480p 每秒 12 / 720p 每秒 24kling3.0 为 480p 每秒 15 / 720p 每秒 30kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40
角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点
生成音效:10 泥点
生成背景音乐:5 泥点
@@ -128,9 +127,9 @@
当前必须显式覆盖的正式模型定价配置:
- 图片类:`gemini-3.1-flash-image-preview`UI 显示与历史别名统一为 `nanobanana2``gpt-image-2`
- 图片类:`gemini-3.1-flash-image-preview`UI 显示与历史别名统一为 `nanobanana2`为 12 泥点;`gpt-image-2` 为 20 泥点
- 规范:`gpt-image-2`
- 视频:`seedance2.0-fast``seedance2.0``kling3.0``kling3.0-omni`兼容旧布局回放的 `veo3.1``veo3.1-fast` 也要保留定价配置,但前端模型菜单不展示。
- 视频:`seedance2.0-fast` 为 10 / 20 泥点每秒;`seedance2.0` 为 12 / 24 泥点每秒;`kling3.0` 为 15 / 30 泥点每秒;`kling3.0-omni` 为 20 / 40 泥点每秒。兼容旧布局回放的 `veo3.1``veo3.1-fast` 也要保留 10 / 20 泥点每秒定价配置,但前端模型菜单不展示。
- 角色动画:`seedance2.0-fast`
- 音效:`audio1.0`
- 背景音乐:`chirp-v5`
@@ -185,7 +184,8 @@
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后固定用 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成 spritesheet,再复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
- 生成游戏音效面板底部不显示 `type` / `tempo` 字段标题,只有一个音效参数按钮type 只支持 `one-shot` / `loop`BPM 为 `1-300` 的数字或 `null`,选择后按钮显示 `单次·120BPM` 这类组合值
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。
- Seedance 2.0 参考视频 / 音频上传不产生 `data:video/*` / 大体积音频 JSON;提交请求优先携带 `objectKey`,后端 Ark body 含 `generate_audio:false` 且不含未确认的 `mode` 字段。
@@ -13,23 +13,23 @@
- `生成游戏音效`
- `生成游戏背景音乐`
3. 选择某一项后创建独立 `generation-dialog` 画布生成对象,并通过现有 placement 模型避让已有图层和占位。
4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 生成按钮区,不写规则说明类文案。
4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 模型 / 生成按钮区,不写规则说明类文案。音效参数按钮靠左下角,固定模型胶囊紧贴生成按钮;背景音乐同样在右下角显示固定模型胶囊并紧贴生成按钮。
5. 生成中隐藏设置面板,只保留画布中的音频生成占位;失败后恢复面板并展示短错误。
## 面板字段
### 生成游戏音效
- `prompt`:用户输入的音效提示词,直接作为 Vidu 文生音频请求的 `prompt` 字段
- `model`:固定默认 `audio1.0`UI 显示为 `Vidu`;暂不展示 Suno 文生音效模型入口。
- `prompt`:用户输入的音效提示词。前端与 BFF 对内仍使用 `prompt`,提交到 VectorEngine Vidu 时同时写入 `prompt``sound` 同值,兼容线上网关对 `sound` 字段的反序列化要求
- `model`:固定默认 `audio1.0`UI 以禁用态模型胶囊显示为 `Vidu`,位置紧贴生成按钮;暂不展示 Suno 文生音效模型入口。
- `duration`:Vidu 音频时长,UI 以一个无标题参数按钮展示,选项范围 `2-10` 秒,默认 `5` 秒。
- 提交到 VectorEngine 时映射为 Vidu 文生音频字段:`model: "audio1.0"``prompt``duration`、可选 `seed`。当前编辑器音效固定使用 Vidu `audio1.0`,不再走 Suno `task: "sound"` / `metadata_params` 文生音效契约。
- 提交到 VectorEngine 时映射为 Vidu 文生音频字段:`model: "audio1.0"``prompt``sound: prompt``duration`、可选 `seed`。当前编辑器音效固定使用 Vidu `audio1.0`,不再走 Suno `task: "sound"` / `metadata_params` 文生音效契约。
### 生成游戏背景音乐
- `gpt_description_prompt`:用户输入的背景音乐提示词。
- `make_instrumental`:固定传 `true`,不在 UI 中展示为可改字段。
- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv``gpt_description_prompt``make_instrumental: true``mv` 后端固定使用默认 Suno 模型,不在 UI 中展示
- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv``gpt_description_prompt``make_instrumental: true``mv` 后端固定使用默认 Suno 模型,UI 以禁用态模型胶囊显示 `Suno`
- `gpt_description_prompt` 按 Apifox 契约限制 200 字,超出时由 BFF 返回参数错误。
## 画布数据
@@ -42,9 +42,10 @@
- 新增素材类型:
- `assetKind="sound-effect"`
- `assetKind="background-music"`
- 音频结果以小型音频卡加入画布,卡片底部使用 `<audio controls>` 播放;生成成功后同时保存为 OSS 私有对象、画布资源和账号级素材库素材,响应携带 `objectKey` / `assetObjectId` 供后续换签和复用。
- 音频结果以小型音频卡加入画布,卡片底部使用融入卡片的自定义播放器组件承载进度、时间和音量,播放 / 暂停只收口到卡片中央图标按钮;生成成功后同时保存为 OSS 私有对象、画布资源和账号级素材库素材,响应携带 `objectKey` / `assetObjectId` 供后续换签和复用。
- 普通素材上传入口首版支持图片、MP3 和 MP4;MP3 / MP4 先走 OSS 直传和 asset object confirm,再以素材库素材保存,其他音视频格式暂不开放。
- 音频结果卡片底部显示用户提示词与播放控件,不在卡片左下角或悬停左上角展示时长。
- 音频结果卡片底部显示播放器辅助控件,不在卡片左下角或悬停左上角展示时长;提示词固定显示在卡片左上角
- 音频结果卡片底部播放器辅助控件仅在鼠标悬停音频卡片时从下方滑入显示,移出后向下滑出收起。
- 音频卡片中央播放区按状态切换:未悬停且未播放时显示音效 / 背景音乐图标,悬停且未播放时显示播放按钮,播放中始终显示暂停按钮。
- generated 私有音频资源播放前必须通过 `/api/assets/read-url` 换签;画布卡片不得直接把 `/generated-*` 或 generated OSS 私有地址交给 `<audio>` 裸请求。
- 音频元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义;音频生成占位不显示分辨率或时长角标。
@@ -103,7 +104,7 @@ POST /api/editor/audios/background-music/generations
-`platform-audio` 增加编辑器专用 body builder 和 submit 函数:
- 背景音乐 body 使用 `mv``gpt_description_prompt``make_instrumental`
- Suno 音乐接口路径固定为 `/suno/submit/music``VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`
- 音效 body 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"``prompt``duration` 和可选 `seed``model``prompt`必填`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10` 秒。
- 音效 body 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"``prompt``sound: prompt``duration` 和可选 `seed``model``prompt`文档必填,`sound` 用于兼容线上网关实际校验`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10` 秒。
- 编辑器音效轮询使用 Vidu 路径 `/ent/v2/tasks/{taskId}/creations`,不再使用 Suno `/suno/fetch/{taskId}`Suno 文生音效 `task: "sound"` 暂不从编辑器入口暴露。
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路,避免误伤 `/api/editor/audios/background-music/generations`
- VectorEngine 音频响应的 `code` 需要兼容 `"success"``"ok"``"0"``"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。
@@ -117,15 +118,16 @@ POST /api/editor/audios/background-music/generations
- 底部工具栏显示 `生成音乐`
- 点击 `生成音乐` 只出现选项框,不立刻创建占位。
- 点击 `生成游戏音效` 后出现音效面板,文本字段为 `prompt`;底部只有一个无标题时长参数按钮。
- 点击 `生成游戏音效` 后出现音效面板,文本字段为 `prompt`;底部左侧只有一个无标题时长参数按钮,右侧为固定 `Vidu` 模型胶囊和生成按钮
- 音效时长只提供 `2-10` 秒选项,默认 `5` 秒,提交到 BFF 的字段为 `duration`
- 音效面板模型显示 `Vidu`,提交 `model: "audio1.0"`;不显示 Suno 文生音效模型或 Suno 音效入口。
- 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`,不展示 `make_instrumental`
- 音效面板模型显示 `Vidu`,提交 `model: "audio1.0"`后端转发到 Vidu 时同时携带 `prompt``sound`不显示 Suno 文生音效模型或 Suno 音效入口。
- 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`右侧固定显示 `Suno` 模型胶囊,不展示 `make_instrumental`
- 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`
- 成功后画布新增音频卡,能通过 `<audio controls>` 播放
- 成功后画布新增音频卡,能通过卡片中央播放按钮播放,底部进度、时间和音量控件可操作
- 成功后音频素材自动出现在账号级素材库;从素材库再次添加到画布时仍恢复为 `mediaType="audio"` 音频图层。
- 普通素材上传 MP3 / MP4 会写入 OSS 并进入素材库;MP3 添加到画布为音频图层,MP4 添加到画布为视频图层。
- 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,播放控件固定在卡片底部。
- 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,不在底部重复显示播放 / 暂停按钮,播放器辅助控件直接融入卡片底部。
- 鼠标未悬停音频卡时底部播放器辅助控件不可见,悬停时从底部滑动显现;提示词位于卡片左上角。
- 音频卡片、悬停角标和待生成占位不显示时长;信息弹窗显示时长。
- 音频素材浮动工具栏只显示 `改造``下载按钮``改造` 复用对应生成面板且没有参考图组件。
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
+24
View File
@@ -75,6 +75,30 @@ export type ProfileWalletLedgerResponse = {
entries: ProfileWalletLedgerEntry[];
};
export type ExternalApiKeyProfile = {
keyId: string;
name: string;
keyPrefix: string;
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
revokedAt: string | null;
updatedAt: string;
};
export type ExternalApiKeyListResponse = {
keys: ExternalApiKeyProfile[];
};
export type ExternalApiKeyCreateResponse = {
apiKey: string;
key: ExternalApiKeyProfile;
};
export type ExternalApiKeyMutationResponse = {
key: ExternalApiKeyProfile;
};
export type ProfileRechargeProductKind = 'points' | 'membership';
export type ProfileMembershipStatus = 'normal' | 'active';
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
+1
View File
@@ -42,6 +42,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::internal::router(state.clone()))
.merge(modules::auth::router(state.clone()))
.merge(modules::profile::router(state.clone()))
.merge(modules::external_api::router(state.clone()))
.merge(modules::assets::router(state.clone()))
.merge(modules::editor_project::router(state.clone()))
.merge(modules::platform::router(state.clone()))
@@ -4852,7 +4852,7 @@ mod tests {
resolution: "480p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
price_mud_points: 50,
price_mud_points: 100,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
@@ -4864,7 +4864,7 @@ mod tests {
assert_eq!(normalized.provider_model, EDITOR_VIDEO_MODEL_KLING_3_OMNI);
assert_eq!(normalized.width, 854);
assert_eq!(normalized.height, 480);
assert_eq!(normalized.price_mud_points, 50);
assert_eq!(normalized.price_mud_points, 100);
}
#[test]
@@ -4899,7 +4899,7 @@ mod tests {
resolution: "720p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
price_mud_points: 80,
price_mud_points: 96,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
@@ -5051,7 +5051,7 @@ mod tests {
resolution: "480p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
price_mud_points: 40,
price_mud_points: 48,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: vec!["data:audio/wav;base64,audio".to_string()],
@@ -7,6 +7,7 @@ const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
const EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS: u32 = 12;
const EDITOR_IMAGE_MODEL_GPT_IMAGE_2_MUD_POINTS: u32 = 20;
const EDITOR_SPEC_MODEL_GPT_IMAGE_2_MUD_POINTS: u32 = 5;
pub(crate) const EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND: u32 = 10;
@@ -29,8 +30,8 @@ const EDITOR_BACKGROUND_MUSIC_MODEL_SUNO_MUD_POINTS: u32 = 5;
pub(crate) fn editor_image_model_generation_mud_points(model: Option<&str>) -> u32 {
match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2)
| Some(EDITOR_IMAGE_MODEL_NANOBANANA2)
Some(EDITOR_IMAGE_MODEL_GPT_IMAGE_2) => EDITOR_IMAGE_MODEL_GPT_IMAGE_2_MUD_POINTS,
Some(EDITOR_IMAGE_MODEL_NANOBANANA2)
| Some(EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS)
| Some(EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS) => {
EDITOR_IMAGE_MODEL_GENERATION_MUD_POINTS
@@ -60,7 +61,7 @@ fn editor_video_model_resolution_mud_points_per_second(
model: Option<&str>,
resolution: &str,
) -> u32 {
let _normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(
EDITOR_VIDEO_MODEL_SEEDANCE_2
| EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST
@@ -71,12 +72,16 @@ fn editor_video_model_resolution_mud_points_per_second(
) => model.unwrap_or_default().trim(),
_ => EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST,
};
let per_second = if resolution == "720p" {
EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND
} else {
EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND
let (rate_480p, rate_720p) = match normalized_model {
EDITOR_VIDEO_MODEL_SEEDANCE_2 => (12, 24),
EDITOR_VIDEO_MODEL_KLING_3 => (15, 30),
EDITOR_VIDEO_MODEL_KLING_3_OMNI => (20, 40),
_ => (
EDITOR_VIDEO_GENERATION_480P_MUD_POINTS_PER_SECOND,
EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND,
),
};
per_second
if resolution == "720p" { rate_720p } else { rate_480p }
}
pub(crate) fn editor_video_model_generation_mud_points(
@@ -157,15 +162,15 @@ mod tests {
);
assert_eq!(
editor_video_model_generation_mud_points(Some("seedance2.0"), "720p", 5),
100
120
);
assert_eq!(
editor_video_model_generation_mud_points(Some("kling3.0"), "480p", 5),
50
75
);
assert_eq!(
editor_video_model_generation_mud_points(Some("kling3.0-omni"), "720p", 4),
80
160
);
assert_eq!(
editor_video_model_generation_mud_points(Some("veo3.1"), "480p", 4),
@@ -219,7 +224,7 @@ mod tests {
);
assert_eq!(
editor_image_generation_mud_points(Some("publication-material"), Some("gpt-image-2")),
12
20
);
}
}
@@ -54,12 +54,12 @@ use crate::{
state::AppState,
};
const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
@@ -88,8 +88,8 @@ pub struct EditorCanvasViewportPayload {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectLayoutSaveRequest {
viewport: EditorCanvasViewportPayload,
layers: Value,
pub(crate) viewport: EditorCanvasViewportPayload,
pub(crate) layers: Value,
}
#[derive(Debug, Deserialize)]
@@ -932,13 +932,16 @@ pub async fn generate_editor_image(
.unwrap_or((1024, 1024));
let persisted = if is_character_generation {
Some(
persist_editor_character_image(
persist_editor_generated_image(
&state,
authenticated.claims().user_id(),
generated.task_id.as_str(),
&image,
submitted_prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_CHARACTER_IMAGE_ASSET_KIND,
"character-images",
EDITOR_CHARACTER_IMAGE_SLOT,
)
.await?,
)
@@ -1345,7 +1348,9 @@ pub async fn extract_editor_ui_design_assets(
))
}
fn editor_project_payload_from_record(record: EditorProjectRecord) -> EditorProjectPayload {
pub(crate) fn editor_project_payload_from_record(
record: EditorProjectRecord,
) -> EditorProjectPayload {
let canvas = editor_canvas_payload_from_record(record.canvas);
EditorProjectPayload {
project_id: record.project_id,
@@ -1382,7 +1387,7 @@ fn editor_canvas_payload_from_record(record: EditorCanvasRecord) -> EditorCanvas
}
}
fn editor_project_resource_payload_from_record(
pub(crate) fn editor_project_resource_payload_from_record(
record: EditorProjectResourceRecord,
) -> EditorProjectResourcePayload {
EditorProjectResourcePayload {
@@ -1436,7 +1441,7 @@ fn editor_asset_folder_payload_from_record(
}
}
fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
EditorAssetPayload {
asset_id: record.asset_id,
folder_id: record.folder_id,
@@ -1458,7 +1463,7 @@ fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPay
}
impl EditorCanvasViewportPayload {
fn into_record(self) -> EditorCanvasViewportRecord {
pub(crate) fn into_record(self) -> EditorCanvasViewportRecord {
EditorCanvasViewportRecord {
x: self.x,
y: self.y,
@@ -1471,7 +1476,7 @@ fn current_owner_user_id(authenticated: &AuthenticatedAccessToken) -> String {
authenticated.claims().user_id().to_string()
}
fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
pub(crate) fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
let payload = serde_json::to_string(&layers).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
@@ -1489,13 +1494,13 @@ fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
Ok(payload)
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
pub(crate) fn normalize_optional_string(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String {
pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.chars()
@@ -1620,7 +1625,7 @@ fn prepare_editor_character_image_for_response(
}
}
fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String {
pub(crate) fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String {
format!(
"data:{};base64,{}",
mime_type,
@@ -1648,18 +1653,21 @@ struct EditorGenerationOptions {
provider_image_size: &'static str,
}
struct PersistedEditorGeneratedImage {
object_key: String,
asset_object_id: String,
pub(crate) struct PersistedEditorGeneratedImage {
pub(crate) object_key: String,
pub(crate) asset_object_id: String,
}
async fn persist_editor_character_image(
pub(crate) async fn persist_editor_generated_image(
state: &AppState,
owner_user_id: &str,
task_id: &str,
image: &DownloadedOpenAiImage,
prompt: &str,
actual_prompt: Option<&str>,
asset_kind: &str,
path_kind: &str,
slot: &str,
) -> Result<PersistedEditorGeneratedImage, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
@@ -1672,7 +1680,7 @@ async fn persist_editor_character_image(
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: vec![
"editor".to_string(),
"character-images".to_string(),
sanitize_editor_storage_segment(path_kind, "generated-images"),
sanitize_editor_storage_segment(task_id, "task"),
],
file_stem: "image".to_string(),
@@ -1682,11 +1690,11 @@ async fn persist_editor_character_image(
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string()),
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
entity_id: Some(task_id.to_string()),
slot: Some(EDITOR_CHARACTER_IMAGE_SLOT.to_string()),
slot: Some(slot.to_string()),
provider: Some("vector-engine".to_string()),
task_id: Some(task_id.to_string()),
},
@@ -1728,7 +1736,7 @@ async fn persist_editor_character_image(
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
Some(actual_prompt.unwrap_or(prompt).to_string()),
EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string(),
asset_kind.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
None,
@@ -1751,7 +1759,7 @@ async fn persist_editor_character_image(
})
}
fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
pub(crate) fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
let Some((header, data)) = source.trim().split_once(',') else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -1803,7 +1811,7 @@ fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, Ap
})
}
fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
match error {
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
@@ -1829,14 +1837,14 @@ fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
}
}
fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError {
pub(crate) fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
fn current_utc_micros() -> i64 {
pub(crate) fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
@@ -2068,8 +2076,8 @@ mod tests {
12
);
assert_eq!(
validate_editor_icon_spritesheet_price(12, Some("gpt-image-2")).unwrap(),
12
validate_editor_icon_spritesheet_price(20, Some("gpt-image-2")).unwrap(),
20
);
let error = validate_editor_icon_spritesheet_price(5, Some("gpt-image-2"))
.expect_err("wrong icon spritesheet price should fail");
@@ -0,0 +1,91 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
middleware::Next,
response::Response,
};
use spacetime_client::ExternalApiKeyAuthenticateRecordInput;
use tracing::warn;
use crate::{
editor_project::current_utc_micros,
external_api_keys::{hash_external_api_key, map_external_api_key_error},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
#[derive(Clone, Debug)]
pub struct ExternalApiPrincipal {
owner_user_id: String,
key_id: String,
scopes: Vec<String>,
}
impl ExternalApiPrincipal {
pub fn owner_user_id(&self) -> &str {
self.owner_user_id.as_str()
}
pub fn key_id(&self) -> &str {
self.key_id.as_str()
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|item| item == scope)
}
}
pub async fn require_external_api_key(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let request_id = request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let raw_key = extract_external_api_bearer(request.headers())?;
let key = state
.spacetime_client()
.authenticate_external_api_key(ExternalApiKeyAuthenticateRecordInput {
key_hash: hash_external_api_key(raw_key.as_str()),
used_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
warn!(
%request_id,
error = %error,
"外部 API Key 校验失败"
);
map_external_api_key_error(error)
})?;
let principal = ExternalApiPrincipal {
owner_user_id: key.owner_user_id,
key_id: key.key_id,
scopes: key.scopes,
};
request.extensions_mut().insert(principal.clone());
let mut response = next.run(request).await;
response.extensions_mut().insert(principal);
Ok(response)
}
fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError> {
let authorization = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
authorization
.strip_prefix("Bearer ")
.or_else(|| authorization.strip_prefix("bearer "))
.map(str::trim)
.filter(|token| !token.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))
}
@@ -0,0 +1,243 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
};
use platform_auth::hash_refresh_session_token;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_kernel::{build_prefixed_uuid_id, new_uuid_simple_string};
use spacetime_client::{
ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput,
SpacetimeClientError,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken,
editor_project::current_utc_micros, http_error::AppError, request_context::RequestContext,
state::AppState,
};
const EXTERNAL_API_KEY_ID_PREFIX: &str = "external-api-key-";
const EXTERNAL_API_KEY_SECRET_PREFIX: &str = "tnr_sk_";
const EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS: usize = 18;
const EXTERNAL_API_KEY_SCOPES: [&str; 3] =
["editor:project", "editor:canvas", "editor:image-generate"];
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyCreateRequest {
name: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyPayload {
key_id: String,
name: String,
key_prefix: String,
scopes: Vec<String>,
created_at: String,
last_used_at: Option<String>,
revoked_at: Option<String>,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyCreateResponse {
api_key: String,
key: ExternalApiKeyPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyListResponse {
keys: Vec<ExternalApiKeyPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalApiKeyResponse {
key: ExternalApiKeyPayload,
}
pub async fn list_external_api_keys(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let keys = state
.spacetime_client()
.list_external_api_keys(authenticated.claims().user_id().to_string())
.await
.map_err(map_external_api_key_error)?
.into_iter()
.map(external_api_key_payload_from_record)
.collect();
Ok(json_success_body(
Some(&request_context),
ExternalApiKeyListResponse { keys },
))
}
pub async fn create_external_api_key(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ExternalApiKeyCreateRequest>,
) -> Result<Json<Value>, AppError> {
let raw_key = generate_external_api_key_secret();
let key_prefix = external_api_key_prefix(raw_key.as_str());
let key = state
.spacetime_client()
.create_external_api_key(ExternalApiKeyCreateRecordInput {
key_id: build_prefixed_uuid_id(EXTERNAL_API_KEY_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
name: payload
.name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "外部 API Key".to_string()),
key_prefix,
key_hash: hash_external_api_key(raw_key.as_str()),
scopes: default_external_api_key_scopes(),
now_micros: current_utc_micros(),
})
.await
.map_err(map_external_api_key_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalApiKeyCreateResponse {
api_key: raw_key,
key: external_api_key_payload_from_record(key),
},
))
}
pub async fn revoke_external_api_key(
State(state): State<AppState>,
Path(key_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let key = state
.spacetime_client()
.revoke_external_api_key(ExternalApiKeyRevokeRecordInput {
key_id,
owner_user_id: authenticated.claims().user_id().to_string(),
revoked_at_micros: current_utc_micros(),
})
.await
.map_err(map_external_api_key_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalApiKeyResponse {
key: external_api_key_payload_from_record(key),
},
))
}
pub(crate) fn hash_external_api_key(raw_key: &str) -> String {
hash_refresh_session_token(raw_key)
}
pub(crate) fn default_external_api_key_scopes() -> Vec<String> {
EXTERNAL_API_KEY_SCOPES
.iter()
.map(|scope| (*scope).to_string())
.collect()
}
fn generate_external_api_key_secret() -> String {
format!(
"{EXTERNAL_API_KEY_SECRET_PREFIX}{}.{}",
new_uuid_simple_string(),
new_uuid_simple_string()
)
}
fn external_api_key_prefix(raw_key: &str) -> String {
raw_key
.chars()
.take(EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS)
.collect()
}
fn external_api_key_payload_from_record(record: ExternalApiKeyRecord) -> ExternalApiKeyPayload {
ExternalApiKeyPayload {
key_id: record.key_id,
name: record.name,
key_prefix: record.key_prefix,
scopes: record.scopes,
created_at: record.created_at,
last_used_at: record.last_used_at,
revoked_at: record.revoked_at,
updated_at: record.updated_at,
}
}
pub(crate) fn map_external_api_key_error(error: SpacetimeClientError) -> AppError {
match error {
SpacetimeClientError::Procedure(message)
if message.contains("不存在") || message.contains("已失效") =>
{
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "external-api-key",
"message": message,
}))
}
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "external-api-key",
"message": message,
}))
}
SpacetimeClientError::Runtime(message) | SpacetimeClientError::Procedure(message) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "external-api-key",
"message": message,
}))
}
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": other.to_string(),
})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_external_api_key_uses_public_prefix_only_for_display() {
let raw_key = generate_external_api_key_secret();
let key_prefix = external_api_key_prefix(raw_key.as_str());
assert!(raw_key.starts_with(EXTERNAL_API_KEY_SECRET_PREFIX));
assert_eq!(
key_prefix.chars().count(),
EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS
);
assert!(raw_key.starts_with(key_prefix.as_str()));
assert_ne!(hash_external_api_key(raw_key.as_str()), raw_key);
}
#[test]
fn default_external_api_key_scopes_cover_editor_openapi_v1() {
let scopes = default_external_api_key_scopes();
assert_eq!(
scopes,
vec![
"editor:project".to_string(),
"editor:canvas".to_string(),
"editor:image-generate".to_string(),
]
);
}
}
@@ -0,0 +1,439 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::{StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorProjectCreateRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectResourceCreateRecordInput,
};
use crate::{
api_response::json_success_body,
editor_project::{
EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE, EDITOR_PROJECT_ID_PREFIX,
EDITOR_RESOURCE_ID_PREFIX, EditorAssetPayload, EditorCanvasViewportPayload,
EditorProjectPayload, EditorProjectResourcePayload, current_utc_micros,
data_url_from_image_bytes, editor_asset_payload_from_record,
editor_project_payload_from_record, editor_project_resource_payload_from_record,
map_editor_project_error, normalize_optional_string, persist_editor_generated_image,
serialize_editor_layers,
},
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
openai_image_generation::{
GPT_IMAGE_2_MODEL, build_openai_image_http_client,
create_openai_image_edit_with_references_and_model,
create_openai_image_generation_with_model, require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
};
const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api";
const EXTERNAL_EDITOR_IMAGE_ASSET_KIND: &str = "editor_external_image";
const EXTERNAL_EDITOR_IMAGE_SLOT: &str = "external-image";
const EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE: &str = "generated";
const EXTERNAL_EDITOR_IMAGE_PROVIDER: &str = "VectorEngine";
const EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE: &str = "1024x1024";
const SCOPE_EDITOR_PROJECT: &str = "editor:project";
const SCOPE_EDITOR_CANVAS: &str = "editor:canvas";
const SCOPE_EDITOR_IMAGE_GENERATE: &str = "editor:image-generate";
const OPENAPI_JSON: &str =
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectCreateRequest {
title: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorCanvasSaveRequest {
viewport: EditorCanvasViewportPayload,
layers: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorImageGenerationRequest {
prompt: String,
project_id: Option<String>,
title: Option<String>,
size: Option<String>,
reference_image_srcs: Option<Vec<String>>,
layers: Option<Value>,
viewport: Option<EditorCanvasViewportPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectResponse {
project: EditorProjectPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorImageGenerationResponse {
image_src: String,
width: u32,
height: u32,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: &'static str,
task_id: String,
asset: EditorAssetPayload,
resource: Option<EditorProjectResourcePayload>,
project: Option<EditorProjectPayload>,
}
pub async fn openapi_json() -> Response {
(
[(CONTENT_TYPE, "application/json; charset=utf-8")],
OPENAPI_JSON,
)
.into_response()
}
pub async fn create_external_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorProjectCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let project = state
.spacetime_client()
.create_editor_project(EditorProjectCreateRecordInput {
project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX),
owner_user_id: principal.owner_user_id().to_string(),
title: normalize_project_title(payload.title),
now_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn get_external_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn save_external_editor_canvas(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorCanvasSaveRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_CANVAS)?;
let project = state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
viewport: payload.viewport.into_record(),
layers_json: serialize_editor_layers(payload.layers)?,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn generate_external_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<ExternalEditorImageGenerationRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
let prompt = payload.prompt.trim().to_string();
if prompt.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"message": "生成提示词不能为空",
})),
);
}
let project_id = normalize_optional_string(payload.project_id);
let asset_title = normalize_asset_title(payload.title.as_deref(), prompt.as_str());
let size = normalize_external_image_size(payload.size.as_deref());
let reference_images = payload
.reference_image_srcs
.unwrap_or_default()
.into_iter()
.filter_map(|source| {
let trimmed = source.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
})
.take(5)
.map(|source| crate::editor_project::parse_editor_reference_image(source.as_str()))
.collect::<Result<Vec<_>, _>>()?;
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(principal.owner_user_id().to_string()),
project_id.clone(),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = if reference_images.is_empty() {
create_openai_image_generation_with_model(
&http_client,
&settings,
GPT_IMAGE_2_MODEL,
prompt.as_str(),
None,
size.as_str(),
1,
&[],
"外部 OpenAPI 编辑器美术生图",
)
.await?
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
GPT_IMAGE_2_MODEL,
prompt.as_str(),
None,
size.as_str(),
1,
reference_images.as_slice(),
"外部 OpenAPI 编辑器参考图美术生图",
)
.await?
};
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
let image_src = data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
let persisted = persist_editor_generated_image(
&state,
principal.owner_user_id(),
generated.task_id.as_str(),
&image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EXTERNAL_EDITOR_IMAGE_ASSET_KIND,
"external-images",
EXTERNAL_EDITOR_IMAGE_SLOT,
)
.await?;
let now_micros = current_utc_micros();
let asset = state
.spacetime_client()
.create_editor_asset(EditorAssetCreateRecordInput {
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
owner_user_id: principal.owner_user_id().to_string(),
folder_id: default_asset_folder_id(principal.owner_user_id()),
label: asset_title,
asset_object_id: Some(persisted.asset_object_id.clone()),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
width,
height,
source_type: EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE.to_string(),
prompt: Some(prompt.clone()),
actual_prompt: generated.actual_prompt.clone(),
model: Some(GPT_IMAGE_2_MODEL.to_string()),
provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()),
task_id: Some(generated.task_id.clone()),
now_micros,
})
.await
.map_err(map_editor_project_error)?;
let resource = if let Some(project_id) = project_id.clone() {
Some(
state
.spacetime_client()
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
project_id,
owner_user_id: principal.owner_user_id().to_string(),
asset_object_id: Some(persisted.asset_object_id.clone()),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
width,
height,
source_type: EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE.to_string(),
prompt: Some(prompt.clone()),
actual_prompt: generated.actual_prompt.clone(),
model: Some(GPT_IMAGE_2_MODEL.to_string()),
provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()),
task_id: Some(generated.task_id.clone()),
source_resource_id: None,
updated_at_micros: now_micros,
})
.await
.map_err(map_editor_project_error)?,
)
} else {
None
};
let project = if let (Some(project_id), Some(layers), Some(viewport)) =
(project_id, payload.layers, payload.viewport)
{
Some(
state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
viewport: viewport.into_record(),
layers_json: serialize_editor_layers(layers)?,
updated_at_micros: now_micros,
})
.await
.map_err(map_editor_project_error)?,
)
} else {
None
};
Ok(json_success_body(
Some(&request_context),
ExternalEditorImageGenerationResponse {
image_src,
width,
height,
prompt,
actual_prompt: generated.actual_prompt,
model: GPT_IMAGE_2_MODEL.to_string(),
provider: EXTERNAL_EDITOR_IMAGE_PROVIDER,
task_id: generated.task_id,
asset: editor_asset_payload_from_record(asset),
resource: resource.map(editor_project_resource_payload_from_record),
project: project.map(editor_project_payload_from_record),
},
))
}
fn require_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), AppError> {
if principal.has_scope(scope) {
return Ok(());
}
Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"keyId": principal.key_id(),
"scope": scope,
"message": "API Key 缺少所需权限",
})),
)
}
fn normalize_project_title(title: Option<String>) -> String {
title
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string())
}
fn normalize_asset_title(title: Option<&str>, prompt: &str) -> String {
title
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| prompt.trim().split('\n').next())
.map(|value| value.chars().take(40).collect())
.unwrap_or_else(|| "外部生成图片".to_string())
}
fn normalize_external_image_size(size: Option<&str>) -> String {
match size.map(str::trim).filter(|value| !value.is_empty()) {
Some(value @ ("1024x1024" | "1536x1024" | "1024x1536" | "2048x1152" | "2048x2048")) => {
value.to_string()
}
_ => EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE.to_string(),
}
}
fn default_asset_folder_id(owner_user_id: &str) -> String {
format!("{owner_user_id}:asset-folder:project")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exported_openapi_json_contains_external_editor_routes_and_security() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
assert_eq!(parsed["openapi"], "3.1.0");
assert!(
parsed["paths"]
.get("/api/external/v1/editor/projects")
.is_some()
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/generations")
.is_some()
);
assert_eq!(
parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"],
"bearer"
);
}
#[test]
fn external_image_size_falls_back_to_default_for_unknown_values() {
assert_eq!(
normalize_external_image_size(Some("1536x1024")),
"1536x1024"
);
assert_eq!(
normalize_external_image_size(Some("4096x4096")),
EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE
);
assert_eq!(
normalize_external_image_size(None),
EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE
);
}
}
+3
View File
@@ -42,6 +42,9 @@ mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;
mod external_api_audit;
mod external_api_auth;
mod external_api_keys;
mod external_editor_api;
mod external_generation;
mod external_generation_worker;
mod external_generation_worker_controller;
@@ -6,6 +6,7 @@ pub mod big_fish;
pub mod custom_world;
pub mod editor_project;
pub mod edutainment;
pub mod external_api;
pub mod external_generation;
pub mod health;
pub mod internal;
@@ -0,0 +1,54 @@
use axum::{
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, patch, post},
};
use crate::{
external_api_auth::require_external_api_key,
external_editor_api::{
create_external_editor_project, generate_external_editor_image,
get_external_editor_project, openapi_json, save_external_editor_canvas,
},
state::AppState,
};
const EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/api/external/v1/openapi.json", get(openapi_json))
.route(
"/api/external/v1/editor/projects",
post(create_external_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects/{project_id}",
get(get_external_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects/{project_id}/canvas",
patch(save_external_editor_canvas).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/images/generations",
post(generate_external_editor_image)
.layer(DefaultBodyLimit::max(
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state,
require_external_api_key,
)),
)
}
@@ -1,10 +1,11 @@
use axum::{
Router, middleware,
routing::{get, patch, post},
routing::{delete, get, patch, post},
};
use crate::{
auth::require_bearer_auth,
external_api_keys::{create_external_api_key, list_external_api_keys, revoke_external_api_key},
profile_identity::update_profile_identity,
runtime_profile::{
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
@@ -32,6 +33,22 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/profile/api-keys",
get(list_external_api_keys)
.post(create_external_api_key)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/api-keys/{key_id}",
delete(revoke_external_api_key).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
@@ -47,7 +47,10 @@ pub fn build_sound_effect_task_body(request: SoundEffectTaskRequest) -> Result<V
"model".to_string(),
Value::String(VIDU_AUDIO_MODEL.to_string()),
),
("prompt".to_string(), Value::String(prompt)),
("prompt".to_string(), Value::String(prompt.clone())),
// 中文注释:VectorEngine Apifox 当前仍写 prompt,但线上 Vidu 网关曾返回
// missing field sound;同时发送 sound 作为同义字段,兼容网关实际反序列化。
("sound".to_string(), Value::String(prompt)),
("duration".to_string(), json!(request.duration)),
]);
if let Some(seed) = request.seed {
@@ -89,7 +92,9 @@ pub fn build_editor_sound_effect_task_body(
}
let mut body = Map::from_iter([
("model".to_string(), Value::String(model)),
("prompt".to_string(), Value::String(prompt)),
("prompt".to_string(), Value::String(prompt.clone())),
// 中文注释:同上,编辑器音效也必须带 sound,避免上游按 sound 字段解析时报缺字段。
("sound".to_string(), Value::String(prompt)),
("duration".to_string(), json!(request.duration)),
]);
if let Some(seed) = request.seed {
@@ -142,6 +142,7 @@ fn vidu_sound_effect_request_body_uses_text2audio_contract() {
assert_eq!(AudioTaskKind::SoundEffect.provider(), "vector-engine-vidu");
assert_eq!(body["model"], VIDU_AUDIO_MODEL);
assert_eq!(body["prompt"], "金币掉落叮当声");
assert_eq!(body["sound"], "金币掉落叮当声");
assert_eq!(body["duration"], 5);
assert!(body.get("mv").is_none());
assert!(body.get("task").is_none());
@@ -182,6 +183,7 @@ fn editor_sound_effect_request_body_uses_vidu_text2audio_contract() {
assert_eq!(body["model"], VIDU_AUDIO_MODEL);
assert_eq!(body["prompt"], "金币掉落叮当声");
assert_eq!(body["sound"], "金币掉落叮当声");
assert_eq!(body["duration"], 7);
assert_eq!(body["seed"], 42);
assert!(body.get("mv").is_none());
@@ -1048,7 +1048,7 @@ mod tests {
resolution: "480p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
price_mud_points: 50,
price_mud_points: 100,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
@@ -1057,7 +1057,7 @@ mod tests {
assert_eq!(payload["aspectRatio"], json!("16:9"));
assert_eq!(payload["durationSeconds"], json!(5));
assert_eq!(payload["priceMudPoints"], json!(50));
assert_eq!(payload["priceMudPoints"], json!(100));
assert_eq!(payload["model"], json!("kling3.0-omni"));
}
@@ -1111,7 +1111,7 @@ mod tests {
task_id: "task-1".to_string(),
duration_seconds: 5,
resolution: "480p".to_string(),
price_mud_points: 50,
price_mud_points: 100,
})
.expect("response should serialize");
@@ -0,0 +1,94 @@
use super::*;
impl SpacetimeClient {
pub async fn create_external_api_key(
&self,
input: ExternalApiKeyCreateRecordInput,
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"create_external_api_key_and_return",
move |connection, sender| {
connection
.procedures()
.create_external_api_key_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_api_key_single_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn list_external_api_keys(
&self,
owner_user_id: String,
) -> Result<Vec<ExternalApiKeyRecord>, SpacetimeClientError> {
let procedure_input = ExternalApiKeyListInput { owner_user_id };
self.call_after_connect(
"list_external_api_keys_and_return",
move |connection, sender| {
connection
.procedures()
.list_external_api_keys_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_api_key_list_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn revoke_external_api_key(
&self,
input: ExternalApiKeyRevokeRecordInput,
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"revoke_external_api_key_and_return",
move |connection, sender| {
connection
.procedures()
.revoke_external_api_key_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_api_key_single_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn authenticate_external_api_key(
&self,
input: ExternalApiKeyAuthenticateRecordInput,
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"authenticate_external_api_key_and_return",
move |connection, sender| {
connection
.procedures()
.authenticate_external_api_key_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_external_api_key_single_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
}
+13 -10
View File
@@ -37,16 +37,18 @@ pub use mapper::{
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
EditorProjectResourceRecord, ExternalGenerationJobClaimRecordInput,
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput,
ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput,
ExternalGenerationQueueStatsRecord, JumpHopActionRequest, JumpHopActionResponse,
JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse,
JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse,
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult,
JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse,
JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
EditorProjectResourceRecord, ExternalApiKeyAuthenticateRecordInput,
ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput,
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput,
ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord,
ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord,
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse,
@@ -125,6 +127,7 @@ pub mod big_fish;
pub mod combat;
pub mod custom_world;
pub mod editor_project;
pub mod external_api_key;
pub mod external_generation;
pub mod inventory;

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