切换 Tiantoken 并删除旧 Vidu 音效
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m47s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m48s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m43s
Project CI / AI game creator shell Rust crates (push) Successful in 2m3s
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Frontend tests (push) Successful in 4m53s
Project CI / Native shell tests (push) Successful in 6m35s
Project CI / Backend tests (push) Successful in 7m25s
Project CI / AI game creator shell web tests (push) Successful in 2m35s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m47s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m48s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m43s
Project CI / AI game creator shell Rust crates (push) Successful in 2m3s
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Frontend tests (push) Successful in 4m53s
Project CI / Native shell tests (push) Successful in 6m35s
Project CI / Backend tests (push) Successful in 7m25s
Project CI / AI game creator shell web tests (push) Successful in 2m35s
新增 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配置并切换文本与图片链路 保留 Suno VectorEngine 路径,删除旧 Vidu submit/poll/builder 实现 同步脚本、文档、测试与运行时 provider 路由
This commit is contained in:
+10
-5
@@ -1,8 +1,8 @@
|
||||
# Server-side OpenAI-compatible LLM endpoint base URL.
|
||||
LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
LLM_BASE_URL="https://api.tiantoken.com/v1"
|
||||
|
||||
# Server-side API key used by the local Vite proxy.
|
||||
# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY`
|
||||
# Recommended: set `LLM_API_KEY` locally, or use `TIANTOKEN_API_KEY`
|
||||
# through the Rust api-server proxy.
|
||||
# Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy,
|
||||
# but it should not be relied on by browser code.
|
||||
@@ -122,7 +122,7 @@ WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
|
||||
# Model name for chat completions.
|
||||
VITE_LLM_MODEL="gpt-5.4-mini"
|
||||
GENARRATIVE_LLM_PROVIDER="openai-compatible"
|
||||
GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
GENARRATIVE_LLM_BASE_URL="https://api.tiantoken.com/v1"
|
||||
GENARRATIVE_LLM_API_KEY=""
|
||||
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
|
||||
|
||||
@@ -130,10 +130,15 @@ GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
|
||||
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
|
||||
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
|
||||
|
||||
# VectorEngine LLM and GPT-image-2 / Gemini image generation config.
|
||||
# Tiantoken LLM and GPT-image-2 / Gemini image generation config.
|
||||
TIANTOKEN_BASE_URL="https://api.tiantoken.com"
|
||||
TIANTOKEN_API_KEY=""
|
||||
TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS="1000000"
|
||||
|
||||
# VectorEngine is retained for Suno audio generation only.
|
||||
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn"
|
||||
VECTOR_ENGINE_API_KEY=""
|
||||
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000"
|
||||
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS="180000"
|
||||
|
||||
# ElevenLabs editor sound-effect generation is server-side only.
|
||||
ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
|
||||
|
||||
@@ -62,7 +62,7 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
|
||||
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
|
||||
|
||||
GENARRATIVE_LLM_PROVIDER=openai-compatible
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1
|
||||
GENARRATIVE_LLM_API_KEY=
|
||||
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
|
||||
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
|
||||
|
||||
Vendored
+4
-2
@@ -79,7 +79,7 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
|
||||
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
|
||||
|
||||
GENARRATIVE_LLM_PROVIDER=openai-compatible
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1
|
||||
GENARRATIVE_LLM_API_KEY=
|
||||
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
|
||||
# LLM Router 正式账号链路:production 固定使用官方地址/模型;管理员 Token 只读受保护文件。
|
||||
@@ -87,9 +87,11 @@ GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1
|
||||
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=/etc/genarrative/secrets/llm-router-provisioning.secret
|
||||
GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/llm-router-api-key-encryption.secret
|
||||
GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token
|
||||
TIANTOKEN_BASE_URL=https://api.tiantoken.com
|
||||
TIANTOKEN_API_KEY=
|
||||
TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS=1000000
|
||||
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn
|
||||
VECTOR_ENGINE_API_KEY=
|
||||
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000
|
||||
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000
|
||||
ELEVENLABS_BASE_URL=https://api.elevenlabs.io
|
||||
ELEVENLABS_API_KEY=
|
||||
|
||||
@@ -8740,3 +8740,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 决策(面板可关 + 非模态任务面板):两块生成浮层在提交期间放开 × / 遮罩 / Esc,提交按钮旁给「后台运行并关闭」;**关闭 ≠ 取消**(表单的 `await` 挂在该任务的终局上,不是面板生命周期)。新增「生成任务」非模态浮层(不铺遮罩、不做焦点陷阱、**不进** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 遮挡判据),入口按钮 `aria-label="生成任务"`;已完成的条目按 `assetId` 复用既有 `pendingResourceFocusRef` 聚焦链定位素材卡。
|
||||
- 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`(+ `main.rs` 注册)、`src/features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx}`;改动 `ResourceCanvasAssetGenerationPanelView.tsx` / `ResourceCanvasGenerationPanelView.tsx` / `src/view/project-development/index.tsx`;测试改动 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationQueue.test.ts,resourceCanvasAssetGenerationTasksPanel.test.tsx}`(新增)与 `tests/appSurface/project-development.suite.ts`(把「每个入口一次 `generate_local_project_asset`」改成 `start_local_project_asset_generation` + `list_...` 轮询桩,载荷断言逐字不变)。**未动**:external v1 / OpenAPI、`packages/`、SpacetimeDB、音频入口的 pending-edit 账本语义、生成参数与 IPC 载荷字段名。
|
||||
- 关联文档:`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 / §4a / §8)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a / §7.3)。
|
||||
|
||||
## 2026-09-15 非 Suno 的 VectorEngine 能力切换到 Tiantoken
|
||||
|
||||
- 决策:新增本地私密环境变量 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`(图片 timeout 可独立配置),承载原 VectorEngine 的文本和图片;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐与 Suno 音效。编辑器 SFX V2 继续走 ElevenLabs。
|
||||
- 实现边界:api-server 在创建状态时冻结 Tiantoken 配置,LLM、图片和旧版非 Suno 音频按该配置路由;Suno 的提交 / 轮询仍使用旧 VectorEngine 配置。旧 `vector_engine_*` 测试构造保留为 Tiantoken fallback,生产新环境变量优先。
|
||||
- 验证:Tiantoken `/v1/models` 返回 HTTP 200(126 个模型,含 `gpt-image-2`、`gpt-5.4-mini`);api-server Tiantoken 配置单测、platform-audio 全量测试、图片定向测试、前端 `apiClient` 定向测试、`npm run typecheck`、`npm run check:api-server-env`、编码 / fmt / diff 检查通过。未对音频上游提交生成任务,模型列表未列出 audio / Vidu 条目。
|
||||
|
||||
## 2026-09-15 删除旧版 Vidu 音效实现
|
||||
|
||||
- 决策:旧版 Vidu `audio1.0` 的 submit / poll / download builder、旧视觉小说与创建音效死代码、对应 platform-audio 请求类型和测试全部删除。历史素材的 `audio1.0` 展示与定价兼容数据保留;新编辑器音效仍只走 ElevenLabs,Suno 音乐链路不变。
|
||||
- 验证:platform-audio 全量测试 55 条通过,api-server `cargo check` 通过,fmt / 编码 / diff 检查通过;仓库现役源码不再包含 `VIDU_AUDIO_MODEL`、`AudioTaskKind::SoundEffect` 或 Vidu submit/poll 实现。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -214,7 +214,7 @@ spacetime sql <database> "SELECT * FROM runtime_setting LIMIT 1" --server http:/
|
||||
|
||||
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.8.3`,CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。若版本或 commit 错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install <version> && spacetime version use <version>`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.8.3 + 8e410d28...` 同时匹配;旧版本或旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。
|
||||
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 VectorEngine provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine 配置仅保留给 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 Tiantoken provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
||||
|
||||
编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL`、`ELEVENLABS_API_KEY` 和 `ELEVENLABS_REQUEST_TIMEOUT_MS`,timeout 默认 `180000ms`;base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`;Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。
|
||||
|
||||
@@ -790,14 +790,14 @@ PowerShell 下按测试文件头部示例依次设置三个必填变量,并按
|
||||
|
||||
该用例只从进程环境变量读取凭据,不读 `.env.secrets.local`,也不会写入任何文件。真实 API Key 一律不得提交进仓库,也不要写进 `docs/`、脚本默认值或测试 fixture;临时密钥用完应在上游及时吊销。
|
||||
|
||||
创意 Agent `gpt-5` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于 Responses 协议。排查或切换密钥后,可在本地运行:
|
||||
创意 Agent `gpt-5.4-mini` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`,未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。排查或切换密钥后,可在本地运行:
|
||||
创意 Agent 文本链路使用 Tiantoken:`api-server` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于文本协议。排查或切换密钥后,可在本地运行:
|
||||
创意 Agent `gpt-5.4-mini` 文本链路使用 Tiantoken:`api-server` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`,未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 Tiantoken 凭据。排查或切换密钥后,可在本地运行:
|
||||
|
||||
```bash
|
||||
node scripts/test-ve-llm.mjs
|
||||
```
|
||||
|
||||
该脚本读取仓库根目录 `.env.secrets.local` 中的 `VECTOR_ENGINE_BASE_URL` 和 `VECTOR_ENGINE_API_KEY`,依次探测 `/v1/models`、`/v1/chat/completions`、`/v1/responses`、`gpt-5.4-mini` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。
|
||||
该脚本读取仓库根目录 `.env.secrets.local` 中的 `TIANTOKEN_BASE_URL` 和 `TIANTOKEN_API_KEY`,依次探测 `/v1/models`、`/v1/chat/completions`、`/v1/responses`、`gpt-5.4-mini` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。
|
||||
|
||||
### 手机验证码短信
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ SFX V2 已完成产品与技术口径冻结及 T0–T6 工程实施;这不表
|
||||
|
||||
- `prompt`:用户在输入框确认的原始语言音效描述,语义为 `userPrompt`,继续复用对外请求和持久化字段 `prompt`。消费动作前按 ECMAScript `String.trim()` 语义只删除首尾空白和行终止符,包含 `U+FEFF`;不改写内部空白、换行、标点、零宽字符或 Unicode 形式。首尾 `U+0085` 不属于该删除集合。按 Unicode code point 计数,合法范围为 `1-2048`。空值和全空白不得回退“游戏音效”。
|
||||
- `actualPrompt`:Worker 在正式生成时对冻结的 `userPrompt` 进行统一英文化并严格验收后得到的英文 Prompt,继续复用持久化字段 `actual_prompt`。前端不提交 `actualPrompt`,ElevenLabs 只接收验收通过的 `actualPrompt`。
|
||||
- `model`:新任务固定 `eleven_text_to_sound_v2`,UI 以禁用态模型胶囊显示 `ElevenLabs`,客户端不决定模型。历史 `audio1.0` 只作旧素材展示和其它未迁移调用方的兼容标识,不是新编辑器 SFX 任务的 alias 或 fallback。
|
||||
- `model`:新任务固定 `eleven_text_to_sound_v2`,UI 以禁用态模型胶囊显示 `ElevenLabs`,客户端不决定模型。历史 `audio1.0` 只作旧素材展示标识,不是新编辑器 SFX 任务的 alias 或 fallback。
|
||||
- `duration`:`null` 表示自动时长,有限数值表示手动时长。首次打开面板默认处于自动模式,并预置最近手动值为 `5s`;手动范围 `0.5-30s`,UI 步进 `0.1s`。自动模式禁用 slider 但保留最近手动值,关闭自动后恢复该值。服务端只校验有限值与范围,不把 UI 步进扩大成 provider 精度限制。
|
||||
- `loop`:独立布尔参数,默认 `false`,由页面开关原样冻结并传入 ElevenLabs。Prompt 是自由文本,系统不从 Prompt 推断、同步或校验 Loop;Prompt 文本与 Loop 开关不建立业务一致性门禁。
|
||||
- `prompt_influence`:服务端固定 `0.3`,不向前端开放滑杆或请求字段。输出格式固定为 query `output_format=mp3_44100_128`。
|
||||
@@ -468,7 +468,7 @@ POST / api / editor / audios / background - music / prompts / simplifications;
|
||||
- 背景音乐在 body builder 边界防御性执行幂等 canonicalization,再按 canonical Prompt 检查至少一个有效字符和最多 200 个 Unicode code point;校验通过后用 canonical Prompt 构造 Suno body。不得复用语义不同的 `normalize_limited_text`,不得提供默认 Prompt。
|
||||
- Suno 音乐接口路径固定为 `/suno/submit/music`;`VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`。
|
||||
- 新编辑器音效使用独立 ElevenLabs 直接二进制 adapter,不伪装成 Vidu / Suno 的 submit + poll 任务。adapter 负责 endpoint 归一、`xi-api-key` header、固定 query / body、单次 POST、有界二进制读取、MP3 验证和时长探测。
|
||||
- Vidu `audio1.0` 的 body builder、轮询和下载能力仅保留给历史展示和其它未迁移调用方;新 `audio-sound-effect` 任务不进入 `/ent/v2/text2audio` 或 `/ent/v2/tasks/{taskId}/creations`,也不使用 Suno `task: "sound"`。
|
||||
- 旧版 Vidu `audio1.0` 的 body builder、轮询和下载代码已移除;历史 `audio1.0` 只读展示,新 `audio-sound-effect` 任务不进入 `/ent/v2/text2audio` 或 `/ent/v2/tasks/{taskId}/creations`,也不使用 Suno `task: "sound"`。
|
||||
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路;`/suno/fetch/{taskId}` 返回 `audiopipe.suno.ai/?item_id=...` 时,该地址只作为 clip id 来源,不作为最终下载文件,后端继续调用 `/suno/act/wav/{clipId}` 获取稳定 wav URL,避免 worker 在不完整 chunked body 上卡满超时。
|
||||
- VectorEngine 音频响应的 `code` 需要兼容 `"success"`、`"ok"`、`"0"`、`"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。
|
||||
- 在 `api-server/src/vector_engine_audio_generation/generation.rs` 原地演进现有编辑器音频 generation handler;以下正式路由保持原路径和现有注册,不新增平行 BFF:
|
||||
@@ -526,7 +526,7 @@ POST / api / editor / audios / background - music / prompts / simplifications;
|
||||
- BGM 边界测试覆盖 200 / 201 个纯 Unicode `White_Space` 均归一为空并禁止三动作、大量边界空白包围 `A` 后只允许生成、`A` 加 199 个内部空格再加 `B` 后只允许简化、201 个 U+200B 或 U+FEFF 只允许简化、边界空白包围 200 个 `A` 后允许补全和生成,以及 TypeScript 与 Rust 对 U+0085、U+200B 和 U+FEFF 的一致行为。
|
||||
- BGM 助手入口测试覆盖 canonical 2000 字允许简化、2001 字返回 `400` 且不调用 LLM;两个助手路由 body 超过 `32 KiB` 时返回 `413`;连续合法请求不因本功能新增限流器返回 `429`。
|
||||
- 两个助手成功路由分别产生 `editor_background_music_prompt_completion` / `editor_background_music_prompt_simplification` tracking event,均为 `module_key = editor`、User scope;失败响应沿用普通 route tracking 只记录成功的现状。
|
||||
- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;历史和其它未迁移 Vidu 调用方的 builder / 轮询能力保持可用,但新编辑器 SFX 任务只调用 ElevenLabs。
|
||||
- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;旧版 Vidu builder / 轮询能力已移除,历史素材只读展示,新编辑器 SFX 任务只调用 ElevenLabs。
|
||||
- `audio-sound-effect` 与 `audio-background-music` 必须由同一个音频 composer 渲染,并在组件内通过 `isSoundEffect` 分支。SFX 不得渲染 BGM 的补全 / 简化、Suno 模型和 BGM 字符规则;BGM 不得渲染 SFX 的一键优化、Loop、ElevenLabs 模型和 SFX 时长控件。两个 mode 相互切换时,菜单、预设滚动、锁、快照和助手状态不得跨分支泄漏。
|
||||
- SFX V2 翻译失败时 ElevenLabs 请求数为 0;成功时每个平台 job 最多一次 ElevenLabs POST,`prompt / actual_prompt`、实际时长、Loop、model、provider 和 Task ID 在队列、素材、画布、响应、刷新和重绘后保持权威一致。
|
||||
- SFX 两类 LLM 请求契约测试分别断言:一键优化每次请求为 Luna + Medium + `max_completion_tokens = 8192`,翻译每次业务尝试为 Luna + Low + `max_completion_tokens = 8192`,两者都不含 `max_tokens`、`max_output_tokens` 或 temperature;测试命名和说明必须把 `8192` 解释为包含 reasoning 的 completion tokens 总预算。优化遇到 `finish_reason = length` 直接失败且不写回;翻译首轮 `length` 只重试一次,第二轮 `length` 最终失败且 ElevenLabs 请求数为 0。
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('bgfilter worker smoke harness', () => {
|
||||
GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'real-internal-token',
|
||||
GENARRATIVE_EDITOR_BGFILTER_TOKEN: 'real-provider-token',
|
||||
PATH: '/safe/bin',
|
||||
VECTOR_ENGINE_API_KEY: 'real-vector-secret',
|
||||
TIANTOKEN_API_KEY: 'real-tiantoken-secret',
|
||||
},
|
||||
providerBaseUrl: 'http://127.0.0.1:19001',
|
||||
tempRoot: '/tmp/bgfilter-load-smoke-test',
|
||||
@@ -40,10 +40,10 @@ describe('bgfilter worker smoke harness', () => {
|
||||
assert.equal(env.ALIYUN_OSS_ENDPOINT, 'oss-cn-shanghai.invalid');
|
||||
assert.notEqual(env.ALIYUN_OSS_ACCESS_KEY_SECRET, 'real-oss-secret');
|
||||
assert.equal(env.GENARRATIVE_EDITOR_BGFILTER_TOKEN, undefined);
|
||||
assert.equal(env.VECTOR_ENGINE_API_KEY, undefined);
|
||||
assert.equal(env.TIANTOKEN_API_KEY, undefined);
|
||||
assert.ok(!Object.values(env).includes('real-internal-token'));
|
||||
assert.ok(!Object.values(env).includes('real-provider-token'));
|
||||
assert.ok(!Object.values(env).includes('real-vector-secret'));
|
||||
assert.ok(!Object.values(env).includes('real-tiantoken-secret'));
|
||||
});
|
||||
|
||||
test('loopback mock 完整读取 multipart 后记录并发并返回合法 PNG 字节', async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mergeApiServerEnv } from './dev-utils.mjs';
|
||||
|
||||
const REQUIRED_FOR_PUZZLE_GENERATION = [
|
||||
'VECTOR_ENGINE_BASE_URL',
|
||||
'VECTOR_ENGINE_API_KEY',
|
||||
'TIANTOKEN_BASE_URL',
|
||||
'TIANTOKEN_API_KEY',
|
||||
'ALIYUN_OSS_BUCKET',
|
||||
'ALIYUN_OSS_ENDPOINT',
|
||||
'ALIYUN_OSS_ACCESS_KEY_ID',
|
||||
|
||||
@@ -514,11 +514,11 @@ GENARRATIVE_SPACETIME_POOL_SIZE=2
|
||||
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15
|
||||
|
||||
GENARRATIVE_LLM_PROVIDER=openai-compatible
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1
|
||||
GENARRATIVE_LLM_API_KEY=
|
||||
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
|
||||
VECTOR_ENGINE_BASE_URL=
|
||||
VECTOR_ENGINE_API_KEY=
|
||||
TIANTOKEN_BASE_URL=
|
||||
TIANTOKEN_API_KEY=
|
||||
ALIYUN_OSS_BUCKET=
|
||||
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=
|
||||
|
||||
@@ -52,12 +52,12 @@ function timestamp() {
|
||||
function resolveEnv() {
|
||||
const env = mergeApiServerEnv(repoRoot, process.env);
|
||||
return {
|
||||
baseUrl: String(env.VECTOR_ENGINE_BASE_URL || '')
|
||||
baseUrl: String(env.TIANTOKEN_BASE_URL || '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, ''),
|
||||
apiKey: String(env.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||
apiKey: String(env.TIANTOKEN_API_KEY || '').trim(),
|
||||
timeoutMs: Number.parseInt(
|
||||
String(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
String(env.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
10,
|
||||
),
|
||||
};
|
||||
@@ -160,12 +160,12 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -202,7 +202,7 @@ async function imageBytesFromPayload(payload, env) {
|
||||
if (b64Images[0]) {
|
||||
return Buffer.from(b64Images[0], 'base64');
|
||||
}
|
||||
throw new Error('VectorEngine returned no image');
|
||||
throw new Error('Tiantoken returned no image');
|
||||
}
|
||||
|
||||
async function generateImage(env, { prompt, negativePrompt, size, outPath }) {
|
||||
@@ -320,7 +320,7 @@ async function main() {
|
||||
{
|
||||
mode: 'dry-run',
|
||||
outDir,
|
||||
message: '加 --live 才会真实调用 VectorEngine。',
|
||||
message: '加 --live 才会真实调用 Tiantoken。',
|
||||
prompts,
|
||||
},
|
||||
null,
|
||||
@@ -332,7 +332,7 @@ async function main() {
|
||||
|
||||
const env = resolveEnv();
|
||||
if (!env.baseUrl || !env.apiKey) {
|
||||
throw new Error('Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY');
|
||||
throw new Error('Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY');
|
||||
}
|
||||
|
||||
console.log(`[match3d-export] 1/4 生成关卡整图 -> ${outDir}`);
|
||||
|
||||
@@ -127,12 +127,12 @@ function resolveEnv() {
|
||||
...process.env,
|
||||
};
|
||||
return {
|
||||
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, ''),
|
||||
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
|
||||
timeoutMs: Number.parseInt(
|
||||
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
10,
|
||||
),
|
||||
};
|
||||
@@ -249,12 +249,12 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -335,7 +335,7 @@ async function generateOne(env, concept, size, references) {
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
};
|
||||
} else {
|
||||
throw new Error(`VectorEngine returned no image for ${concept.id}`);
|
||||
throw new Error(`Tiantoken returned no image for ${concept.id}`);
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
@@ -401,7 +401,7 @@ if (!env.baseUrl || !env.apiKey) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
|
||||
hasBaseUrl: Boolean(env.baseUrl),
|
||||
hasApiKey: Boolean(env.apiKey),
|
||||
}),
|
||||
|
||||
@@ -107,12 +107,12 @@ function resolveEnv() {
|
||||
...process.env,
|
||||
};
|
||||
return {
|
||||
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, ''),
|
||||
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
|
||||
timeoutMs: Number.parseInt(
|
||||
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
10,
|
||||
),
|
||||
};
|
||||
@@ -226,12 +226,12 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -314,7 +314,7 @@ async function generateOne(env, concept, size) {
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
};
|
||||
} else {
|
||||
throw new Error(`VectorEngine returned no image for ${concept.id}`);
|
||||
throw new Error(`Tiantoken returned no image for ${concept.id}`);
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
@@ -375,7 +375,7 @@ if (!env.baseUrl || !env.apiKey) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
|
||||
hasBaseUrl: Boolean(env.baseUrl),
|
||||
hasApiKey: Boolean(env.apiKey),
|
||||
}),
|
||||
|
||||
@@ -87,12 +87,12 @@ function resolveEnv() {
|
||||
...process.env,
|
||||
};
|
||||
return {
|
||||
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, ''),
|
||||
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
|
||||
timeoutMs: Number.parseInt(
|
||||
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault),
|
||||
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault),
|
||||
10,
|
||||
),
|
||||
};
|
||||
@@ -180,12 +180,12 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -235,7 +235,7 @@ async function generateChromaSource() {
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
|
||||
hasBaseUrl: Boolean(env.baseUrl),
|
||||
hasApiKey: Boolean(env.apiKey),
|
||||
}),
|
||||
@@ -263,7 +263,7 @@ async function generateChromaSource() {
|
||||
} else if (b64Images[0]) {
|
||||
bytes = Buffer.from(b64Images[0], 'base64');
|
||||
} else {
|
||||
throw new Error('VectorEngine returned no image');
|
||||
throw new Error('Tiantoken returned no image');
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
@@ -28,12 +28,11 @@ function loadEnv(path) {
|
||||
|
||||
const env = loadEnv(resolve(root, '.env.secrets.local'));
|
||||
const BASE =
|
||||
env.VECTOR_ENGINE_BASE_URL?.replace(/\/+$/, '') ||
|
||||
'https://api.vectorengine.cn';
|
||||
const KEY = env.VECTOR_ENGINE_API_KEY || '';
|
||||
env.TIANTOKEN_BASE_URL?.replace(/\/+$/, '') || 'https://api.tiantoken.com';
|
||||
const KEY = env.TIANTOKEN_API_KEY || '';
|
||||
|
||||
if (!KEY) {
|
||||
console.error('未找到 VECTOR_ENGINE_API_KEY');
|
||||
console.error('未找到 TIANTOKEN_API_KEY');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -90,7 +89,7 @@ async function test(name, method, path, body = null) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`VectorEngine LLM 能力探测`);
|
||||
console.log(`Tiantoken LLM 能力探测`);
|
||||
console.log(`目标: ${BASE}\n`);
|
||||
|
||||
const tests = [
|
||||
@@ -185,12 +184,12 @@ console.log(
|
||||
|
||||
// 结论
|
||||
if (pass >= 3) {
|
||||
console.log('\n✅ VectorEngine 支持 LLM 文本调用,可替代 Apimart。');
|
||||
console.log('\n✅ Tiantoken 支持 LLM 文本调用,可替代 Apimart。');
|
||||
console.log(
|
||||
' 将 .env.secrets.local 中 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY 配好即可。',
|
||||
' 将 .env.secrets.local 中 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配好即可。',
|
||||
);
|
||||
} else if (pass <= 1) {
|
||||
console.log('\n❌ VectorEngine 不支持 LLM 文本调用。');
|
||||
console.log('\n❌ Tiantoken 不支持 LLM 文本调用。');
|
||||
} else {
|
||||
console.log('\n⚠️ 部分支持,需进一步评估。');
|
||||
}
|
||||
|
||||
@@ -1175,6 +1175,7 @@ impl AppConfig {
|
||||
read_first_non_empty_env(&[
|
||||
"GENARRATIVE_LLM_API_KEY",
|
||||
"LLM_API_KEY",
|
||||
"TIANTOKEN_API_KEY",
|
||||
"VECTOR_ENGINE_API_KEY",
|
||||
"ARK_API_KEY",
|
||||
])
|
||||
@@ -1183,6 +1184,7 @@ impl AppConfig {
|
||||
"GENARRATIVE_LLM_API_KEY",
|
||||
"LLM_API_KEY",
|
||||
"ARK_API_KEY",
|
||||
"TIANTOKEN_API_KEY",
|
||||
"VECTOR_ENGINE_API_KEY",
|
||||
])
|
||||
};
|
||||
@@ -1275,11 +1277,12 @@ impl AppConfig {
|
||||
|
||||
config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]);
|
||||
|
||||
if let Some(vector_engine_image_request_timeout_ms) =
|
||||
read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"])
|
||||
{
|
||||
if let Some(tiantoken_image_request_timeout_ms) = read_first_positive_u64_env(&[
|
||||
"TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS",
|
||||
"VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS",
|
||||
]) {
|
||||
// 单次 attempt 上限允许按环境收短;worker 调用还会受整次任务的绝对 deadline 约束。
|
||||
config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms;
|
||||
config.vector_engine_image_request_timeout_ms = tiantoken_image_request_timeout_ms;
|
||||
}
|
||||
|
||||
if let Some(vector_engine_audio_request_timeout_ms) =
|
||||
@@ -1425,6 +1428,20 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiantoken 是图片、文本和旧版非 Suno 音频生成的新 provider。
|
||||
///
|
||||
/// 这里保留对 `AppConfig.vector_engine_*` 的回退,方便测试构造的旧配置继续工作;
|
||||
/// 生产环境一旦设置了新的 `TIANTOKEN_*` 变量,就不会再把非 Suno 请求发往 VectorEngine。
|
||||
pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String {
|
||||
read_first_non_empty_env(&["TIANTOKEN_BASE_URL"])
|
||||
.unwrap_or_else(|| config.vector_engine_base_url.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option<String> {
|
||||
read_first_non_empty_env(&["TIANTOKEN_API_KEY"])
|
||||
.or_else(|| config.vector_engine_api_key.clone())
|
||||
}
|
||||
|
||||
fn read_first_non_empty_env(keys: &[&str]) -> Option<String> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key).ok().and_then(|value| {
|
||||
@@ -1730,6 +1747,7 @@ mod tests {
|
||||
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
|
||||
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode,
|
||||
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
|
||||
tiantoken_api_key, tiantoken_base_url,
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
@@ -1822,6 +1840,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiantoken_provider_prefers_new_env_names_over_legacy_vector_engine_config() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock should not poison");
|
||||
let mut config = AppConfig::default();
|
||||
config.vector_engine_base_url = "https://vector.example.invalid".to_string();
|
||||
config.vector_engine_api_key = Some("legacy-vector-key".to_string());
|
||||
unsafe {
|
||||
std::env::set_var("TIANTOKEN_BASE_URL", " https://api.tiantoken.example/ ");
|
||||
std::env::set_var("TIANTOKEN_API_KEY", " tiantoken-key ");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
tiantoken_base_url(&config),
|
||||
"https://api.tiantoken.example/"
|
||||
);
|
||||
assert_eq!(tiantoken_api_key(&config).as_deref(), Some("tiantoken-key"));
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("TIANTOKEN_BASE_URL");
|
||||
std::env::remove_var("TIANTOKEN_API_KEY");
|
||||
}
|
||||
assert_eq!(
|
||||
tiantoken_base_url(&config),
|
||||
"https://vector.example.invalid"
|
||||
);
|
||||
assert_eq!(
|
||||
tiantoken_api_key(&config).as_deref(),
|
||||
Some("legacy-vector-key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_router_key_encryption_secret_prefers_dedicated_secret_or_derives_from_jwt() {
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
@@ -117,7 +117,7 @@ pub(crate) async fn resolve_editor_screen_background_color(
|
||||
.map(|report| report.allowed.clone())
|
||||
.unwrap_or_else(|| EDITOR_SCREEN_BACKGROUND_COLORS.to_vec());
|
||||
|
||||
// 决策统一走 VectorEngine gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量),
|
||||
// 决策统一走 Tiantoken gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量),
|
||||
// 都不继承 Ark 默认文本模型(豆包,选色能力弱)。仅当 gpt5 客户端未配置时才降级回默认
|
||||
// llm_client;该默认客户端是纯文本模型(Ark),收到图片分片会被上游 400 拒绝,故先丢弃图片分片。
|
||||
let (llm_client, decision_model) = match vision_llm_client {
|
||||
@@ -860,10 +860,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// 真机联调:按 build_editor_agent_llm_client 的方式组 VectorEngine 客户端,直接跑
|
||||
// 真机联调:按 build_editor_agent_llm_client 的方式组 Tiantoken 客户端,直接跑
|
||||
// resolve_editor_screen_background_color 的完整代码路径(无图文本档 + 有图视觉档),
|
||||
// 验证决策请求真的打到 VectorEngine 并被解析成候选色(decision.fallback == false)。
|
||||
// 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 VECTOR_ENGINE_* 才有意义。
|
||||
// 验证决策请求真的打到 Tiantoken 并被解析成候选色(decision.fallback == false)。
|
||||
// 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 TIANTOKEN_* 才有意义。
|
||||
// 运行:cargo test -p api-server --manifest-path server-rs/Cargo.toml \
|
||||
// editor_screen_background_decision::tests::live -- --ignored --nocapture
|
||||
fn read_live_env(key: &str) -> Option<String> {
|
||||
@@ -895,11 +895,11 @@ mod tests {
|
||||
std::env::var(key).ok().or_else(|| map.get(key).cloned())
|
||||
}
|
||||
|
||||
fn build_live_vector_engine_client() -> Option<LlmClient> {
|
||||
fn build_live_tiantoken_client() -> Option<LlmClient> {
|
||||
use platform_llm::{LlmConfig, LlmProvider};
|
||||
|
||||
let base_url = read_live_env("VECTOR_ENGINE_BASE_URL")?;
|
||||
let api_key = read_live_env("VECTOR_ENGINE_API_KEY")?;
|
||||
let base_url = read_live_env("TIANTOKEN_BASE_URL")?;
|
||||
let api_key = read_live_env("TIANTOKEN_API_KEY")?;
|
||||
// 与 state.rs build_editor_agent_llm_client 一致:规整到以 /v1 结尾。
|
||||
let base_url = if base_url.trim_end_matches('/').ends_with("/v1") {
|
||||
base_url.trim_end_matches('/').to_string()
|
||||
@@ -915,8 +915,8 @@ mod tests {
|
||||
0,
|
||||
500,
|
||||
)
|
||||
.expect("live VectorEngine LlmConfig should build");
|
||||
Some(LlmClient::new(config).expect("live VectorEngine LlmClient should build"))
|
||||
.expect("live Tiantoken LlmConfig should build");
|
||||
Some(LlmClient::new(config).expect("live Tiantoken LlmClient should build"))
|
||||
}
|
||||
|
||||
fn solid_source_image_data_url() -> String {
|
||||
@@ -938,10 +938,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"]
|
||||
async fn live_screen_background_decision_hits_vector_engine_without_image() {
|
||||
let Some(client) = build_live_vector_engine_client() else {
|
||||
panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调");
|
||||
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 TIANTOKEN_* 凭证"]
|
||||
async fn live_screen_background_decision_hits_tiantoken_without_image() {
|
||||
let Some(client) = build_live_tiantoken_client() else {
|
||||
panic!("缺少 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY,无法真机联调");
|
||||
};
|
||||
let decision = resolve_editor_screen_background_color(
|
||||
None,
|
||||
@@ -970,16 +970,16 @@ mod tests {
|
||||
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
|
||||
assert!(
|
||||
!decision.fallback,
|
||||
"若走到兜底说明 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
|
||||
"若走到兜底说明 LLM 没答复(Tiantoken 未被成功调用或响应解析失败)"
|
||||
);
|
||||
assert!(decision.attempts >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"]
|
||||
async fn live_screen_background_decision_hits_vector_engine_with_image() {
|
||||
let Some(client) = build_live_vector_engine_client() else {
|
||||
panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调");
|
||||
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 TIANTOKEN_* 凭证"]
|
||||
async fn live_screen_background_decision_hits_tiantoken_with_image() {
|
||||
let Some(client) = build_live_tiantoken_client() else {
|
||||
panic!("缺少 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY,无法真机联调");
|
||||
};
|
||||
let decision = resolve_editor_screen_background_color(
|
||||
None,
|
||||
@@ -1008,7 +1008,7 @@ mod tests {
|
||||
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
|
||||
assert!(
|
||||
!decision.fallback,
|
||||
"若走到兜底说明视觉 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
|
||||
"若走到兜底说明视觉 LLM 没答复(Tiantoken 未被成功调用或响应解析失败)"
|
||||
);
|
||||
assert!(decision.attempts >= 1);
|
||||
}
|
||||
|
||||
@@ -1905,8 +1905,8 @@ mod tests {
|
||||
"code": "UPSTREAM_ERROR",
|
||||
"message": "上游服务请求失败",
|
||||
"details": {
|
||||
"provider": "vector-engine",
|
||||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||
"provider": "tiantoken",
|
||||
"reason": "TIANTOKEN_API_KEY 未配置",
|
||||
"message": "提交编辑器音效任务失败:missing field sound"
|
||||
}
|
||||
},
|
||||
@@ -1918,7 +1918,7 @@ mod tests {
|
||||
|
||||
let message = response_error_message(response).await;
|
||||
|
||||
assert_eq!(message, "VECTOR_ENGINE_API_KEY 未配置");
|
||||
assert_eq!(message, "TIANTOKEN_API_KEY 未配置");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -75,34 +75,29 @@ impl std::fmt::Debug for OpenAiImageSettings {
|
||||
}
|
||||
}
|
||||
|
||||
// 中文注释:api-server 只负责配置、审计和 HTTP envelope,VectorEngine 协议细节统一由 platform-image provider 承接。
|
||||
// 中文注释:api-server 只负责配置、审计和 HTTP envelope,Tiantoken 的 OpenAI-compatible
|
||||
// 图片协议细节统一由 platform-image provider 承接。
|
||||
pub(crate) fn require_openai_image_settings(
|
||||
state: &AppState,
|
||||
) -> Result<OpenAiImageSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
let base_url = state.tiantoken_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 未配置",
|
||||
"provider": "tiantoken",
|
||||
"reason": "TIANTOKEN_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.tiantoken_api_key()
|
||||
.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 未配置",
|
||||
"provider": "tiantoken",
|
||||
"reason": "TIANTOKEN_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
|
||||
@@ -303,6 +303,9 @@ pub struct AppStateInner {
|
||||
editor_generation_pricing_store: EditorGenerationPricingStore,
|
||||
llm_client: Option<LlmClient>,
|
||||
vector_engine_llm_client: Option<LlmClient>,
|
||||
/// 非 Suno 的文本、图片和旧版音频生成 provider 配置。
|
||||
tiantoken_base_url: String,
|
||||
tiantoken_api_key: Option<String>,
|
||||
matting_client: Option<MattingClient>,
|
||||
bgfilter_provider_http_client: reqwest::Client,
|
||||
bgfilter_worker_http_client: reqwest::Client,
|
||||
@@ -600,8 +603,14 @@ impl AppState {
|
||||
config.editor_generation_pricing_override_path.clone(),
|
||||
)
|
||||
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
|
||||
let tiantoken_base_url = crate::config::tiantoken_base_url(&config);
|
||||
let tiantoken_api_key = crate::config::tiantoken_api_key(&config);
|
||||
let llm_client = build_llm_client(&config)?;
|
||||
let vector_engine_llm_client = build_vector_engine_llm_client(&config)?;
|
||||
let vector_engine_llm_client = build_vector_engine_llm_client(
|
||||
&config,
|
||||
&tiantoken_base_url,
|
||||
tiantoken_api_key.as_deref(),
|
||||
)?;
|
||||
let matting_client = build_matting_client(&config)?;
|
||||
let bgfilter_provider_http_client = build_bgfilter_provider_http_client(&config)?;
|
||||
let bgfilter_worker_http_client = build_bgfilter_worker_http_client(&config)?;
|
||||
@@ -677,6 +686,8 @@ impl AppState {
|
||||
editor_generation_pricing_store,
|
||||
llm_client,
|
||||
vector_engine_llm_client,
|
||||
tiantoken_base_url,
|
||||
tiantoken_api_key,
|
||||
matting_client,
|
||||
bgfilter_provider_http_client,
|
||||
bgfilter_worker_http_client,
|
||||
@@ -1579,6 +1590,14 @@ impl AppState {
|
||||
self.vector_engine_llm_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn tiantoken_base_url(&self) -> &str {
|
||||
self.tiantoken_base_url.as_str()
|
||||
}
|
||||
|
||||
pub fn tiantoken_api_key(&self) -> Option<&str> {
|
||||
self.tiantoken_api_key.as_deref()
|
||||
}
|
||||
|
||||
pub fn matting_client(&self) -> Option<&MattingClient> {
|
||||
self.matting_client.as_ref()
|
||||
}
|
||||
@@ -2490,21 +2509,21 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
|
||||
|
||||
fn build_vector_engine_llm_client(
|
||||
config: &AppConfig,
|
||||
tiantoken_base_url: &str,
|
||||
tiantoken_api_key: Option<&str>,
|
||||
) -> Result<Option<LlmClient>, AppStateInitError> {
|
||||
// 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine。
|
||||
let Some(api_key) = config
|
||||
.vector_engine_api_key
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
// 中文注释:Apimart 已于 2026-06 弃用,非 Suno 文本调用统一迁移到 Tiantoken。
|
||||
let Some(api_key) = tiantoken_api_key
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let base_url = if config.vector_engine_base_url.ends_with("/v1") {
|
||||
config.vector_engine_base_url.clone()
|
||||
let base_url = if tiantoken_base_url.trim_end_matches('/').ends_with("/v1") {
|
||||
tiantoken_base_url.trim_end_matches('/').to_string()
|
||||
} else {
|
||||
format!("{}/v1", config.vector_engine_base_url.trim_end_matches('/'))
|
||||
format!("{}/v1", tiantoken_base_url.trim_end_matches('/'))
|
||||
};
|
||||
|
||||
let llm_config = LlmConfig::new(
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
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},
|
||||
generation::normalize_creation_sound_effect_duration,
|
||||
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: normalize_creation_sound_effect_duration(payload.duration)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?,
|
||||
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)))
|
||||
}
|
||||
@@ -11,10 +11,7 @@ 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,
|
||||
},
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST},
|
||||
};
|
||||
|
||||
#[cfg(any())]
|
||||
@@ -208,6 +205,5 @@ pub(super) fn resolve_creation_audio_points_cost(
|
||||
}
|
||||
match slot {
|
||||
AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST,
|
||||
AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
use platform_audio::SoundEffectTaskRequest;
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
use super::{
|
||||
errors::map_platform_audio_error, generation::normalize_creation_sound_effect_duration,
|
||||
settings::require_vector_engine_audio_settings,
|
||||
};
|
||||
|
||||
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: normalize_creation_sound_effect_duration(duration)?,
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_oss::LegacyAssetPrefix;
|
||||
use shared_contracts::{assets, creation_audio};
|
||||
|
||||
use super::{
|
||||
generation::{
|
||||
normalize_creation_sound_effect_duration, normalize_editor_background_music_request,
|
||||
normalize_editor_sound_effect_request,
|
||||
},
|
||||
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(),
|
||||
billing_points_cost: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_audio_points_cost(AudioAssetSlot::SoundEffect, &target),
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_audio_billing_uses_model_price_from_generation_request() {
|
||||
let target = AudioAssetBindingTarget {
|
||||
entity_kind: "editor_audio".to_string(),
|
||||
entity_id: "task-editor-audio".to_string(),
|
||||
slot: "background_music".to_string(),
|
||||
asset_kind: "editor_background_music".to_string(),
|
||||
profile_id: None,
|
||||
storage_prefix: LegacyAssetPrefix::CharacterDrafts,
|
||||
storage_scope: "editor_audio".to_string(),
|
||||
billing_points_cost: Some(12),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target),
|
||||
12
|
||||
);
|
||||
}
|
||||
|
||||
#[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: "wooden_fish_work".to_string(),
|
||||
entity_id: "wooden-fish-profile-1".to_string(),
|
||||
slot: "hit_sound".to_string(),
|
||||
asset_kind: "wooden_fish_hit_sound".to_string(),
|
||||
profile_id: Some("wooden-fish-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets),
|
||||
};
|
||||
let error = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect)
|
||||
.expect_err("wooden fish hit sound target should be disabled");
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_sound_effect_request_normalizes_prompt_duration_and_resolves_price() {
|
||||
let normalized =
|
||||
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
|
||||
prompt: " 金币掉落叮当声 ".to_string(),
|
||||
model: None,
|
||||
duration: 7,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
asset_folder_id: None,
|
||||
asset_label: None,
|
||||
})
|
||||
.expect("editor sound effect request should normalize");
|
||||
|
||||
assert_eq!(normalized.prompt, "金币掉落叮当声");
|
||||
assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL);
|
||||
assert_eq!(normalized.duration, 7);
|
||||
assert_eq!(normalized.price_mud_points, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_sound_effect_request_accepts_only_vidu_audio_model() {
|
||||
let normalized =
|
||||
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
|
||||
prompt: "金币掉落叮当声".to_string(),
|
||||
model: Some(" audio1.0 ".to_string()),
|
||||
duration: 5,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
asset_folder_id: None,
|
||||
asset_label: None,
|
||||
})
|
||||
.expect("Vidu audio model should be accepted");
|
||||
|
||||
assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL);
|
||||
|
||||
let error = normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
|
||||
prompt: "金币掉落叮当声".to_string(),
|
||||
model: Some(platform_audio::SUNO_DEFAULT_MODEL.to_string()),
|
||||
duration: 5,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
asset_folder_id: None,
|
||||
asset_label: None,
|
||||
})
|
||||
.expect_err("Suno text-to-sound should be disabled for editor sound effects");
|
||||
|
||||
assert!(error.to_string().contains("Vidu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_sound_effect_request_rejects_duration_outside_vidu_range() {
|
||||
for duration in [1, 11] {
|
||||
let error =
|
||||
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
|
||||
prompt: "按钮点击".to_string(),
|
||||
model: None,
|
||||
duration,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
asset_folder_id: None,
|
||||
asset_label: None,
|
||||
})
|
||||
.expect_err("duration outside 2-10 seconds should fail");
|
||||
|
||||
assert!(error.to_string().contains("2-10"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_sound_effect_duration_rejects_outside_vidu_range() {
|
||||
assert_eq!(
|
||||
normalize_creation_sound_effect_duration(None).expect("default duration should pass"),
|
||||
platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_creation_sound_effect_duration(Some(2)).expect("2 seconds should pass"),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_creation_sound_effect_duration(Some(10)).expect("10 seconds should pass"),
|
||||
10
|
||||
);
|
||||
|
||||
for duration in [Some(1), Some(11)] {
|
||||
let error = normalize_creation_sound_effect_duration(duration)
|
||||
.expect_err("duration outside Vidu 2-10 seconds should fail");
|
||||
assert!(error.to_string().contains("2-10"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_background_music_request_forces_instrumental_and_resolves_price() {
|
||||
let normalized =
|
||||
normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest {
|
||||
gpt_description_prompt: " 森林冒险背景音乐 ".to_string(),
|
||||
make_instrumental: false,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
asset_folder_id: None,
|
||||
asset_label: None,
|
||||
})
|
||||
.expect("editor background music request should normalize");
|
||||
|
||||
assert_eq!(normalized.gpt_description_prompt, "森林冒险背景音乐");
|
||||
assert!(normalized.make_instrumental);
|
||||
assert_eq!(normalized.price_mud_points, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_background_music_request_rejects_prompt_over_documented_limit() {
|
||||
let error =
|
||||
normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest {
|
||||
gpt_description_prompt: "乐".repeat(201),
|
||||
make_instrumental: true,
|
||||
project_id: None,
|
||||
canvas_completion: None,
|
||||
generation_inputs: None,
|
||||
asset_folder_id: None,
|
||||
asset_label: None,
|
||||
})
|
||||
.expect_err("Suno gpt_description_prompt should follow Apifox 200 char limit");
|
||||
|
||||
assert!(error.to_string().contains("gpt_description_prompt"));
|
||||
}
|
||||
@@ -14,7 +14,6 @@ pub(super) const MUSIC_SLOT: &str = "music";
|
||||
#[cfg(any())]
|
||||
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 {
|
||||
@@ -31,14 +30,12 @@ pub(super) struct AudioAssetBindingTarget {
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +62,6 @@ impl AudioAssetSlot {
|
||||
pub(super) fn creation_contract_kind(self) -> creation_audio::CreationAudioGenerationKind {
|
||||
match self {
|
||||
Self::BackgroundMusic => creation_audio::CreationAudioGenerationKind::BackgroundMusic,
|
||||
Self::SoundEffect => creation_audio::CreationAudioGenerationKind::SoundEffect,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user