From 29e84a77d1bf94ea656f31f635831d6cb3baf57e Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 19:43:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=87=E6=8D=A2=20Tiantoken=20=E5=B9=B6?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A7=20Vidu=20=E9=9F=B3=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配置并切换文本与图片链路 保留 Suno VectorEngine 路径,删除旧 Vidu submit/poll/builder 实现 同步脚本、文档、测试与运行时 provider 路由 --- .env.example | 15 +- deploy/container/api-server.env.example | 2 +- deploy/env/api-server.env.example | 6 +- .../shared-memory/decision-log.md | 11 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 5 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 +- ...编辑器】画板音乐生成入口设计-2026-06-18.md | 6 +- scripts/bgfilter-worker-load-smoke.test.mjs | 6 +- scripts/check-api-server-env.mjs | 4 +- scripts/container-worker-smoke.mjs | 6 +- scripts/export-match3d-resource-pipeline.mjs | 16 +- ...ate-edutainment-road-town-map-concepts.mjs | 14 +- ...te-edutainment-toca-world-map-concepts.mjs | 14 +- .../make-taonier-hand-spirit-transparent.mjs | 14 +- scripts/test-ve-llm.mjs | 15 +- server-rs/crates/api-server/src/config.rs | 60 ++++- .../src/editor_screen_background_decision.rs | 38 ++-- .../src/external_generation_worker.rs | 6 +- .../api-server/src/openai_image_generation.rs | 21 +- server-rs/crates/api-server/src/state.rs | 37 ++- .../handlers.rs | 215 ------------------ .../vector_engine_audio_generation/publish.rs | 6 +- .../vector_engine_audio_generation/tasks.rs | 38 ---- .../vector_engine_audio_generation/tests.rs | 212 ----------------- .../vector_engine_audio_generation/types.rs | 4 - server-rs/crates/platform-audio/src/client.rs | 59 +---- server-rs/crates/platform-audio/src/lib.rs | 13 +- .../crates/platform-audio/src/persist.rs | 7 - .../crates/platform-audio/src/request.rs | 54 +---- server-rs/crates/platform-audio/src/types.rs | 25 -- .../tests/vector_engine_audio.rs | 103 +-------- src/services/apiClient.test.ts | 20 +- 32 files changed, 217 insertions(+), 843 deletions(-) delete mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs delete mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs delete mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs diff --git a/.env.example b/.env.example index d8988060b..bf06357e1 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index 6dddc3e82..b4e0286b1 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -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= diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index dd543cd4f..9b508dac1 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -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= diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d35c4436d..d18fbd8ab 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 实现。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index becec30de..f21c0516b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -258,8 +258,9 @@ npm run check:server-rs-ddd - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 - 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,并由客户端 GET-only 对账;本次请求不得附加 `resultPersistenceStarted`。`AlreadyApplied` 仅在 early guard 与最终 procedure 并发相遇时作为底层幂等兜底,复用既有 commit 且不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 - 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 -- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 -- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/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`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 +- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent 文本链路使用 Tiantoken,使用 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 Tiantoken base URL 规范化到 `/v1`。VectorEngine 只保留给 Suno 音乐任务;后续排障时优先确认 Tiantoken `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 +- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`,通用 `/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 凭据承接。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent 客户端来源。 +- 当前 provider 路由:`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 承载原 VectorEngine 的文本和图片能力;旧版 Vidu 音效代码已移除;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐及 Suno 音效任务;新编辑器 SFX V2 继续独立使用 ElevenLabs。 ### 平台适配器:`platform-llm` 公共能力与三协议工具契约 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 7855d1a72..066bc0a5d 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -214,7 +214,7 @@ spacetime sql "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 && spacetime version use `,升级后重启 `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 提交进仓库。 ### 手机验证码短信 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index b81239a14..7e5133dd9 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -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。 diff --git a/scripts/bgfilter-worker-load-smoke.test.mjs b/scripts/bgfilter-worker-load-smoke.test.mjs index 635c09cfd..919dedcef 100644 --- a/scripts/bgfilter-worker-load-smoke.test.mjs +++ b/scripts/bgfilter-worker-load-smoke.test.mjs @@ -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 () => { diff --git a/scripts/check-api-server-env.mjs b/scripts/check-api-server-env.mjs index 8b4d24e5f..21e8f3cf6 100644 --- a/scripts/check-api-server-env.mjs +++ b/scripts/check-api-server-env.mjs @@ -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', diff --git a/scripts/container-worker-smoke.mjs b/scripts/container-worker-smoke.mjs index 881224570..1beed7036 100644 --- a/scripts/container-worker-smoke.mjs +++ b/scripts/container-worker-smoke.mjs @@ -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= diff --git a/scripts/export-match3d-resource-pipeline.mjs b/scripts/export-match3d-resource-pipeline.mjs index 1f192395f..872f1b8d6 100644 --- a/scripts/export-match3d-resource-pipeline.mjs +++ b/scripts/export-match3d-resource-pipeline.mjs @@ -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}`); diff --git a/scripts/generate-edutainment-road-town-map-concepts.mjs b/scripts/generate-edutainment-road-town-map-concepts.mjs index 84d8264c6..14c181782 100644 --- a/scripts/generate-edutainment-road-town-map-concepts.mjs +++ b/scripts/generate-edutainment-road-town-map-concepts.mjs @@ -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), }), diff --git a/scripts/generate-edutainment-toca-world-map-concepts.mjs b/scripts/generate-edutainment-toca-world-map-concepts.mjs index c6bce5c54..ed9f37ab0 100644 --- a/scripts/generate-edutainment-toca-world-map-concepts.mjs +++ b/scripts/generate-edutainment-toca-world-map-concepts.mjs @@ -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), }), diff --git a/scripts/make-taonier-hand-spirit-transparent.mjs b/scripts/make-taonier-hand-spirit-transparent.mjs index 58cf53605..b70a9a9d0 100644 --- a/scripts/make-taonier-hand-spirit-transparent.mjs +++ b/scripts/make-taonier-hand-spirit-transparent.mjs @@ -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 }); diff --git a/scripts/test-ve-llm.mjs b/scripts/test-ve-llm.mjs index ea1b1f76a..e21cec718 100644 --- a/scripts/test-ve-llm.mjs +++ b/scripts/test-ve-llm.mjs @@ -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⚠️ 部分支持,需进一步评估。'); } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index f3b6057f5..94ea4d4e4 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -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 { + 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 { 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(); diff --git a/server-rs/crates/api-server/src/editor_screen_background_decision.rs b/server-rs/crates/api-server/src/editor_screen_background_decision.rs index 434e8f8a3..45c42d631 100644 --- a/server-rs/crates/api-server/src/editor_screen_background_decision.rs +++ b/server-rs/crates/api-server/src/editor_screen_background_decision.rs @@ -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 { @@ -895,11 +895,11 @@ mod tests { std::env::var(key).ok().or_else(|| map.get(key).cloned()) } - fn build_live_vector_engine_client() -> Option { + fn build_live_tiantoken_client() -> Option { 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); } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 722213247..b279b5054 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -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] diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index f0fa7591c..2cf90c425 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -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 { - 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 未配置", })) })?; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index dc7b08503..b4030cd62 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -303,6 +303,9 @@ pub struct AppStateInner { editor_generation_pricing_store: EditorGenerationPricingStore, llm_client: Option, vector_engine_llm_client: Option, + /// 非 Suno 的文本、图片和旧版音频生成 provider 配置。 + tiantoken_base_url: String, + tiantoken_api_key: Option, matting_client: Option, 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, AppStateIni fn build_vector_engine_llm_client( config: &AppConfig, + tiantoken_base_url: &str, + tiantoken_api_key: Option<&str>, ) -> Result, 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( diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs deleted file mode 100644 index 5d3ee628b..000000000 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs +++ /dev/null @@ -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, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = parse_json_payload(&request_context, payload)?; - let settings = require_vector_engine_audio_settings(&state)?; - let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) - .map_err(map_platform_audio_error)?; - let task = platform_audio::submit_background_music_task( - &http_client, - &settings, - BackgroundMusicTaskRequest { - prompt: payload.prompt, - title: payload.title, - tags: payload.tags, - model: payload.model, - instrumental: true, - }, - ) - .await - .map_err(map_platform_audio_error)?; - - Ok(json_success_body( - Some(&request_context), - contract::VisualNovelAudioGenerationTaskResponse { - kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic, - task_id: task.task_id, - provider: task.provider, - status: task.status, - }, - )) -} - -pub async fn create_background_music_task( - State(_state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let _ = parse_json_payload(&request_context, payload)?; - Err(creation_audio_generation_disabled_error() - .into_response_with_context(Some(&request_context))) -} - -pub async fn create_visual_novel_sound_effect_task( - State(state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = parse_json_payload(&request_context, payload)?; - let settings = require_vector_engine_audio_settings(&state)?; - let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) - .map_err(map_platform_audio_error)?; - let task = platform_audio::submit_sound_effect_task( - &http_client, - &settings, - SoundEffectTaskRequest { - prompt: payload.prompt, - duration: 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, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let _ = parse_json_payload(&request_context, payload)?; - Err(creation_audio_generation_disabled_error() - .into_response_with_context(Some(&request_context))) -} - -pub async fn publish_visual_novel_background_music_asset( - State(state): State, - Path(task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - let target = build_visual_novel_audio_target(payload, AudioAssetSlot::BackgroundMusic)?; - publish_generated_audio_asset( - &state, - authenticated.claims().user_id(), - task_id, - AudioAssetSlot::BackgroundMusic, - target, - ) - .await - .map(|payload| { - json_success_body( - Some(&request_context), - contract::VisualNovelGeneratedAudioAssetResponse { - kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic, - task_id: payload.task_id, - provider: payload.provider, - status: payload.status, - asset_object_id: payload.asset_object_id, - asset_kind: payload.asset_kind, - audio_src: payload.audio_src, - }, - ) - }) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} - -pub async fn publish_visual_novel_sound_effect_asset( - State(state): State, - Path(task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - let target = build_visual_novel_audio_target(payload, AudioAssetSlot::SoundEffect)?; - publish_generated_audio_asset( - &state, - authenticated.claims().user_id(), - task_id, - AudioAssetSlot::SoundEffect, - target, - ) - .await - .map(|payload| { - json_success_body( - Some(&request_context), - contract::VisualNovelGeneratedAudioAssetResponse { - kind: contract::VisualNovelAudioGenerationKind::SoundEffect, - task_id: payload.task_id, - provider: payload.provider, - status: payload.status, - asset_object_id: payload.asset_object_id, - asset_kind: payload.asset_kind, - audio_src: payload.audio_src, - }, - ) - }) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} - -pub async fn publish_background_music_asset( - State(_state): State, - Path(_task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(_authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - Err(creation_audio_generation_disabled_error_for_target(payload) - .into_response_with_context(Some(&request_context))) -} - -pub async fn publish_sound_effect_asset( - State(state): State, - Path(task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect) - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - publish_generated_audio_asset( - &state, - authenticated.claims().user_id(), - task_id, - AudioAssetSlot::SoundEffect, - target, - ) - .await - .map(|payload| json_success_body(Some(&request_context), payload)) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs index 5b50e846c..bcd1d7974 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs @@ -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, } } diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs deleted file mode 100644 index 8a17b5466..000000000 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs +++ /dev/null @@ -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, - seed: Option, -) -> Result { - let settings = require_vector_engine_audio_settings(state)?; - let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) - .map_err(map_platform_audio_error)?; - let task = platform_audio::submit_sound_effect_task( - &http_client, - &settings, - SoundEffectTaskRequest { - prompt, - duration: 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, - }) -} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs deleted file mode 100644 index 397e9b373..000000000 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs +++ /dev/null @@ -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")); -} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs index 515962028..7af8eedbc 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs @@ -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, } } } diff --git a/server-rs/crates/platform-audio/src/client.rs b/server-rs/crates/platform-audio/src/client.rs index df8205746..21fce0f3e 100644 --- a/server-rs/crates/platform-audio/src/client.rs +++ b/server-rs/crates/platform-audio/src/client.rs @@ -9,10 +9,8 @@ use crate::response::{ }; use crate::{ AudioError, AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest, - EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, SoundEffectTaskRequest, - VectorEngineAudioSettings, build_background_music_task_body, - build_editor_background_music_task_body, build_editor_sound_effect_task_body, - build_sound_effect_task_body, + EditorBackgroundMusicTaskRequest, VectorEngineAudioSettings, build_background_music_task_body, + build_editor_background_music_task_body, }; pub fn build_vector_engine_audio_http_client( @@ -56,32 +54,6 @@ pub async fn submit_background_music_task( }) } -pub async fn submit_sound_effect_task( - http_client: &reqwest::Client, - settings: &VectorEngineAudioSettings, - request: SoundEffectTaskRequest, -) -> Result { - let body = build_sound_effect_task_body(request)?; - let response = post_vector_engine_json( - http_client, - settings, - AudioTaskKind::SoundEffect.submit_path(), - body, - "提交 Vidu 音效任务失败", - ) - .await?; - let task_id = extract_submit_task_id(&response) - .ok_or_else(|| AudioError::missing_audio("提交 Vidu 音效任务失败:上游未返回任务 ID"))?; - let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into()); - - Ok(AudioTaskResponse { - kind: AudioTaskKind::SoundEffect, - task_id, - provider: AudioTaskKind::SoundEffect.provider().to_string(), - status, - }) -} - pub async fn submit_editor_background_music_task( http_client: &reqwest::Client, settings: &VectorEngineAudioSettings, @@ -108,32 +80,6 @@ pub async fn submit_editor_background_music_task( }) } -pub async fn submit_editor_sound_effect_task( - http_client: &reqwest::Client, - settings: &VectorEngineAudioSettings, - request: EditorSoundEffectTaskRequest, -) -> Result { - let body = build_editor_sound_effect_task_body(request)?; - let response = post_vector_engine_json( - http_client, - settings, - AudioTaskKind::SoundEffect.submit_path(), - body, - "提交编辑器音效任务失败", - ) - .await?; - let task_id = extract_submit_task_id(&response) - .ok_or_else(|| AudioError::missing_audio("提交编辑器音效任务失败:上游未返回任务 ID"))?; - let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into()); - - Ok(AudioTaskResponse { - kind: AudioTaskKind::SoundEffect, - task_id, - provider: AudioTaskKind::SoundEffect.provider().to_string(), - status, - }) -} - async fn fetch_audio_task_payload( http_client: &reqwest::Client, settings: &VectorEngineAudioSettings, @@ -146,7 +92,6 @@ async fn fetch_audio_task_payload( &kind.fetch_path(task_id), match kind { AudioTaskKind::BackgroundMusic => "查询 Suno 背景音乐任务失败", - AudioTaskKind::SoundEffect => "查询 Vidu 音效任务失败", AudioTaskKind::SunoSoundEffect => "查询 Suno 音效任务失败", }, ) diff --git a/server-rs/crates/platform-audio/src/lib.rs b/server-rs/crates/platform-audio/src/lib.rs index 08a1beac5..5af8d9fbc 100644 --- a/server-rs/crates/platform-audio/src/lib.rs +++ b/server-rs/crates/platform-audio/src/lib.rs @@ -18,7 +18,6 @@ pub use background_music_prompt::{ pub use client::{ build_vector_engine_audio_http_client, resolve_audio_task_download_urls, submit_background_music_task, submit_editor_background_music_task, - submit_editor_sound_effect_task, submit_sound_effect_task, }; pub use download::{audio_mime_to_extension, download_generated_audio, normalize_audio_mime_type}; pub use elevenlabs::{ @@ -36,8 +35,7 @@ pub use persist::{ }; pub use request::{ build_background_music_task_body, build_editor_background_music_task_body, - build_editor_sound_effect_task_body, build_sound_effect_task_body, normalize_limited_text, - normalize_limited_text_allow_empty, normalize_optional_text, + normalize_limited_text, normalize_limited_text_allow_empty, normalize_optional_text, }; pub use response::{ extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_task_status, @@ -52,9 +50,8 @@ pub use sound_effect_prompt::{ pub use types::{ AudioTaskKind, AudioTaskResponse, BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS, BackgroundMusicTaskRequest, DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, - EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, MAX_GENERATED_AUDIO_BYTES, - SUNO_DEFAULT_MODEL, SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, SUNO_PROMPT_MAX_CHARS, - SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VECTOR_ENGINE_PROVIDER, - VECTOR_ENGINE_SUNO_PROVIDER, VECTOR_ENGINE_VIDU_PROVIDER, VIDU_AUDIO_MODEL, - VIDU_PROMPT_MAX_CHARS, VectorEngineAudioSettings, + EditorBackgroundMusicTaskRequest, MAX_GENERATED_AUDIO_BYTES, SUNO_DEFAULT_MODEL, + SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, + SUNO_TITLE_MAX_CHARS, VECTOR_ENGINE_PROVIDER, VECTOR_ENGINE_SUNO_PROVIDER, + VectorEngineAudioSettings, }; diff --git a/server-rs/crates/platform-audio/src/persist.rs b/server-rs/crates/platform-audio/src/persist.rs index 79e3a3474..9de275520 100644 --- a/server-rs/crates/platform-audio/src/persist.rs +++ b/server-rs/crates/platform-audio/src/persist.rs @@ -163,12 +163,5 @@ mod tests { file_stem: "background-music".to_string(), } ); - assert_eq!( - GeneratedAudioPersistSource::from_task_kind(AudioTaskKind::SoundEffect), - GeneratedAudioPersistSource { - provider: crate::VECTOR_ENGINE_VIDU_PROVIDER.to_string(), - file_stem: "sound-effect".to_string(), - } - ); } } diff --git a/server-rs/crates/platform-audio/src/request.rs b/server-rs/crates/platform-audio/src/request.rs index 272aac385..802062cdf 100644 --- a/server-rs/crates/platform-audio/src/request.rs +++ b/server-rs/crates/platform-audio/src/request.rs @@ -1,9 +1,8 @@ use serde_json::{Map, Value, json}; use crate::{ - AudioError, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest, - EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, - SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS, + AudioError, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, + SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, }; pub fn build_background_music_task_body( @@ -36,28 +35,6 @@ pub fn build_background_music_task_body( Ok(Value::Object(body)) } -pub fn build_sound_effect_task_body(request: SoundEffectTaskRequest) -> Result { - let prompt = normalize_limited_text(&request.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?; - if !(2..=10).contains(&request.duration) { - return Err(AudioError::invalid_request("duration 必须在 2-10 秒之间")); - } - let mut body = Map::from_iter([ - ( - "model".to_string(), - Value::String(VIDU_AUDIO_MODEL.to_string()), - ), - ("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 { - body.insert("seed".to_string(), json!(seed)); - } - Ok(Value::Object(body)) -} - pub fn build_editor_background_music_task_body( request: EditorBackgroundMusicTaskRequest, ) -> Result { @@ -72,33 +49,6 @@ pub fn build_editor_background_music_task_body( })) } -pub fn build_editor_sound_effect_task_body( - request: EditorSoundEffectTaskRequest, -) -> Result { - let prompt = normalize_limited_text(&request.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?; - if !(2..=10).contains(&request.duration) { - return Err(AudioError::invalid_request("duration 必须在 2-10 秒之间")); - } - let model = normalize_optional_text(request.model.as_deref()) - .unwrap_or_else(|| VIDU_AUDIO_MODEL.to_string()); - if model != VIDU_AUDIO_MODEL { - return Err(AudioError::invalid_request( - "编辑器音效暂只支持 Vidu audio1.0 模型", - )); - } - let mut body = Map::from_iter([ - ("model".to_string(), Value::String(model)), - ("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 { - body.insert("seed".to_string(), json!(seed)); - } - Ok(Value::Object(body)) -} - pub fn normalize_limited_text( value: &str, field: &'static str, diff --git a/server-rs/crates/platform-audio/src/types.rs b/server-rs/crates/platform-audio/src/types.rs index 9a7befe54..65b647d9b 100644 --- a/server-rs/crates/platform-audio/src/types.rs +++ b/server-rs/crates/platform-audio/src/types.rs @@ -1,7 +1,6 @@ #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AudioTaskKind { BackgroundMusic, - SoundEffect, SunoSoundEffect, } @@ -9,7 +8,6 @@ impl AudioTaskKind { pub fn provider(self) -> &'static str { match self { Self::BackgroundMusic => VECTOR_ENGINE_SUNO_PROVIDER, - Self::SoundEffect => VECTOR_ENGINE_VIDU_PROVIDER, Self::SunoSoundEffect => VECTOR_ENGINE_SUNO_PROVIDER, } } @@ -17,7 +15,6 @@ impl AudioTaskKind { pub fn submit_path(self) -> &'static str { match self { Self::BackgroundMusic => "/suno/submit/music", - Self::SoundEffect => "/ent/v2/text2audio", Self::SunoSoundEffect => "/suno/submit/music", } } @@ -25,9 +22,6 @@ impl AudioTaskKind { pub fn fetch_path(self, task_id: &str) -> String { match self { Self::BackgroundMusic => format!("/suno/fetch/{}", urlencoding::encode(task_id)), - Self::SoundEffect => { - format!("/ent/v2/tasks/{}/creations", urlencoding::encode(task_id)) - } Self::SunoSoundEffect => format!("/suno/fetch/{}", urlencoding::encode(task_id)), } } @@ -35,7 +29,6 @@ impl AudioTaskKind { pub fn file_stem(self) -> &'static str { match self { Self::BackgroundMusic => "background-music", - Self::SoundEffect => "sound-effect", Self::SunoSoundEffect => "sound-effect", } } @@ -50,13 +43,6 @@ pub struct BackgroundMusicTaskRequest { pub instrumental: bool, } -#[derive(Clone, Debug)] -pub struct SoundEffectTaskRequest { - pub prompt: String, - pub duration: u8, - pub seed: Option, -} - #[derive(Clone, Debug)] pub struct EditorBackgroundMusicTaskRequest { pub gpt_description_prompt: String, @@ -64,14 +50,6 @@ pub struct EditorBackgroundMusicTaskRequest { pub model: Option, } -#[derive(Clone, Debug)] -pub struct EditorSoundEffectTaskRequest { - pub prompt: String, - pub duration: u8, - pub seed: Option, - pub model: Option, -} - #[derive(Clone, Debug)] pub struct AudioTaskResponse { pub kind: AudioTaskKind, @@ -96,15 +74,12 @@ pub struct DownloadedAudio { pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; pub const VECTOR_ENGINE_SUNO_PROVIDER: &str = "vector-engine-suno"; -pub const VECTOR_ENGINE_VIDU_PROVIDER: &str = "vector-engine-vidu"; pub const SUNO_DEFAULT_MODEL: &str = "chirp-v5"; -pub const VIDU_AUDIO_MODEL: &str = "audio1.0"; pub const SUNO_PROMPT_MAX_CHARS: usize = 5_000; pub const SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS: usize = 200; pub const BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS: usize = SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS * 10; pub const SUNO_TITLE_MAX_CHARS: usize = 80; pub const SUNO_TAGS_MAX_CHARS: usize = 160; -pub const VIDU_PROMPT_MAX_CHARS: usize = 1_500; pub const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5; pub const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024; diff --git a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs index d966359b9..edf5b5054 100644 --- a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs +++ b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs @@ -1,12 +1,10 @@ use platform_audio::{ AudioTaskKind, BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS, BackgroundMusicTaskRequest, - EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, - SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS, + EditorBackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, audio_mime_to_extension, background_music_prompt_char_count, background_music_prompt_effective_char_count, build_background_music_task_body, - build_editor_background_music_task_body, build_editor_sound_effect_task_body, - build_sound_effect_task_body, canonicalize_background_music_prompt, extract_audio_urls, - is_failed_task_status, is_pending_task_status, normalize_audio_mime_type, + build_editor_background_music_task_body, canonicalize_background_music_prompt, + extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_audio_mime_type, normalize_task_status, validate_background_music_completion_prompt, validate_background_music_generation_prompt, }; @@ -271,19 +269,6 @@ fn background_music_request_body_uses_default_model_and_optional_instrumental_fl ); } -#[test] -fn sound_effect_request_rejects_overlong_prompt() { - let prompt = "声".repeat(VIDU_PROMPT_MAX_CHARS + 1); - let error = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest { - prompt, - duration: 5, - seed: None, - }) - .expect_err("long prompt should fail"); - - assert!(error.message().contains("prompt 超过")); -} - #[test] fn editor_background_music_request_body_uses_gpt_description_prompt_and_instrumental() { let fixture = background_music_prompt_canonicalization_fixture(); @@ -357,85 +342,3 @@ fn editor_background_music_request_body_rejects_unicode_whitespace_only_prompt() assert!(error.message().contains("至少需要 1 个有效字符")); } - -#[test] -fn vidu_sound_effect_request_body_uses_text2audio_contract() { - let body = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest { - prompt: " 金币掉落叮当声 ".to_string(), - duration: 5, - seed: None, - }) - .expect("Vidu sound effect body should be valid"); - - assert_eq!( - AudioTaskKind::SoundEffect.submit_path(), - "/ent/v2/text2audio" - ); - 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()); - assert!(body.get("metadata_params").is_none()); -} - -#[test] -fn vidu_sound_effect_request_body_rejects_duration_outside_vidu_range_and_keeps_seed() { - let body = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest { - prompt: "按钮确认短促音".to_string(), - duration: 2, - seed: Some(42), - }) - .expect("Vidu 2 second duration should be accepted"); - assert_eq!(body["duration"], 2); - assert_eq!(body["seed"], 42); - - for duration in [1, 11] { - let error = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest { - prompt: "循环环境音".to_string(), - duration, - seed: None, - }) - .expect_err("duration outside Vidu 2-10 seconds should fail"); - assert!(error.message().contains("duration")); - } -} - -#[test] -fn editor_sound_effect_request_body_uses_vidu_text2audio_contract() { - let body = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest { - prompt: " 金币掉落叮当声 ".to_string(), - duration: 7, - seed: Some(42), - model: Some(VIDU_AUDIO_MODEL.to_string()), - }) - .expect("editor sound effect should use Vidu body"); - - 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()); - assert!(body.get("task").is_none()); - assert!(body.get("metadata_params").is_none()); - assert!(body.get("type").is_none()); - assert!(body.get("tempo").is_none()); -} - -#[test] -fn editor_sound_effect_request_body_rejects_duration_outside_vidu_range() { - for duration in [1, 11] { - let error = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest { - prompt: "按钮确认短促音".to_string(), - duration, - seed: None, - model: None, - }) - .expect_err("duration outside Vidu 2-10 seconds should fail"); - - assert!(error.message().contains("duration")); - } -} diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index f4149b26e..b04406338 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -1069,12 +1069,12 @@ describe('apiClient', () => { code: 'UPSTREAM_ERROR', message: '上游暂不可用', details: { - provider: 'vector-engine', + provider: 'tiantoken', message: - '创建拼图 VectorEngine 图片编辑任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/edits)', + '创建拼图 Tiantoken 图片编辑任务失败:error sending request for url (https://api.tiantoken.com/v1/images/edits)', reason: - '无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', - endpoint: 'https://api.vectorengine.ai/v1/images/edits', + '无法连接 Tiantoken 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', + endpoint: 'https://api.tiantoken.com/v1/images/edits', }, }, meta: {}, @@ -1095,11 +1095,11 @@ describe('apiClient', () => { ), ).rejects.toMatchObject({ message: - '无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', + '无法连接 Tiantoken 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', status: 502, code: 'UPSTREAM_ERROR', details: { - provider: 'vector-engine', + provider: 'tiantoken', }, }); }); @@ -1116,8 +1116,8 @@ describe('apiClient', () => { code: 'SERVICE_UNAVAILABLE', message: '服务暂不可用', details: { - provider: 'vector-engine', - reason: 'VECTOR_ENGINE_API_KEY 未配置', + provider: 'tiantoken', + reason: 'TIANTOKEN_API_KEY 未配置', }, }, meta: {}, @@ -1137,11 +1137,11 @@ describe('apiClient', () => { '执行抓大鹅共创操作失败', ), ).rejects.toMatchObject({ - message: 'VECTOR_ENGINE_API_KEY 未配置', + message: 'TIANTOKEN_API_KEY 未配置', status: 503, code: 'SERVICE_UNAVAILABLE', details: { - provider: 'vector-engine', + provider: 'tiantoken', }, }); });