fix(video): 创作台视频 - 修正同步音频参数与万有引力结果下载地址 (#554)

Original-Commit: e85e78fb898be7ef1fcd2027b62ab43c00dfaa2c
fix(video): 创作台视频 - 修正同步音频参数与万有引力结果下载地址
This commit is contained in:
Codex
2026-09-20 20:01:51 +08:00
committed by ddCat
parent 5045f7a7e1
commit 93bc079b89
8 changed files with 255 additions and 12 deletions

View File

@@ -0,0 +1,51 @@
package protocol
import (
"context"
"encoding/json"
"testing"
)
func TestOfficialLxmoneVideoResultURLs(t *testing.T) {
providers := []string{
"lxmone-wan-videos", "lxmone-wan-channel-s", "lxmone-seedance-videos", "lxmone-h3-max-videos",
"lxmone-sd-videos", "lxmone-sd-mini-videos", "lxmone-grok-videos", "lxmone-h3-workflow",
}
const directURL = "https://cdn.example.com/generated.mp4?signature=test"
for _, provider := range providers {
t.Run(provider, func(t *testing.T) {
adapter := officialPackageAdapter(t, "lxmone-video-suite.yingce-plugin", provider)
for _, tc := range []struct {
name string
payload map[string]any
}{
{"direct_over_relative", map[string]any{"id": "video-test", "status": "completed", "metadata": map[string]any{"url": "/v1/videos/video-test/content", "direct_url": directURL}}},
{"nested_direct_over_relative", map[string]any{"metadata": map[string]any{"url": "/v1/videos/video-test/content"}, "data": map[string]any{"id": "video-test", "status": "completed", "metadata": map[string]any{"url": "/v1/videos/video-test/content", "direct_url": directURL}}}},
{"absolute_url_without_direct", map[string]any{"id": "video-test", "status": "completed", "metadata": map[string]any{"url": directURL}}},
} {
t.Run(tc.name, func(t *testing.T) {
body, err := json.Marshal(tc.payload)
if err != nil {
t.Fatal(err)
}
created, err := adapter.ParseCreate(context.Background(), body)
if err != nil {
t.Fatal(err)
}
polled, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "video-test"}, body)
if err != nil {
t.Fatal(err)
}
if created.Status != StatusSucceeded || polled.Status != StatusSucceeded || created.TaskID != "video-test" || polled.TaskID != "video-test" {
t.Fatalf("unexpected completion: create=%#v poll=%#v", created, polled)
}
for _, result := range []*Result{created.Result, polled.Result} {
if result == nil || len(result.Videos) != 1 || result.Videos[0].URL != directURL {
t.Fatalf("expected direct download URL, got %#v", result)
}
}
})
}
})
}
}

View File

@@ -17,6 +17,20 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
- [x] 修复已在部署预览版验证:从设置页返回画布列表后,菜单背景和圆角保持正常;深浅主题的卡片操作菜单及浅色排序菜单验证通过。 - [x] 修复已在部署预览版验证:从设置页返回画布列表后,菜单背景和圆角保持正常;深浅主题的卡片操作菜单及浅色排序菜单验证通过。
- [ ] 上游集成后复核设置页与其他工作台页面之间的导航。 - [ ] 上游集成后复核设置页与其他工作台页面之间的导航。
## 万有引力视频结果地址
- 已修复:万有引力视频套件更新为 `1.0.1`,8 个协议优先读取 `metadata.direct_url` / `data.metadata.direct_url`,避免完成响应中的相对 `metadata.url` 遮蔽完整视频地址。保留已有完整 URL 回退,宿主下载与 SSRF 校验不变;源码、内嵌接口文档和插件归档同步更新。
- [x] 真实插件归档的 24 组响应回归通过,覆盖创建 / 轮询完成、顶层 / data 内直链优先以及完整 URL 回退;官方插件归档完整性检查通过。
- [x] 修复插件已部署并验证运行时注册版本与归档一致,后端就绪及 API 代理检查通过。从失败任务已记录的完整地址取回 MP4,未发起新的上游生成,也未修改原失败任务与账务状态。
- [ ] 真实创作台新视频任务验证下载、资源转存与结果展示。原失败任务取回的视频保存在受限本地缓存,未自动回填到会话。
## 创作台视频同步音频参数
- 已修复:创作台视频报价、提交和重试共用按所选模型能力整理的配置。不支持同步音频或水印时使用 `false`,支持时遵循模型默认值,不再把全局 `videoGenerateAudio=true` 当成用户请求;模型筛选只使用创作台实际可选的参数。
- [x] 创作台请求配置、模型能力与报价相关的 32 项专项测试通过,前端类型检查通过;覆盖无声视频的报价 / 任务参数、重试元数据以及支持音频时的模型默认值。
- [x] 前端生产构建通过,修复镜像已部署;首页、创作台路由、入口资源和创作台脚本与本次构建逐字节一致,容器健康与 API 就绪检查通过。构建仍提示部分 chunk 超过 500 kB。
- [ ] 在真实创作台使用万有引力渠道的 `minimax-h3-a`,输入“小猫在流泪”,选择 `16:9`、`480P`,检查报价、首次生成和失败后重试;同时确认支持同步音频的模型遵循各自默认值。本次不调用真实上游,生成结果仍待验收。
## 多模态列表与分镜表合并回归 ## 多模态列表与分镜表合并回归
- [x] 合入 PR #549 的图片 / 视频列表分析、动态表格、视频脚本转换和批量生成设置;参考图上限前后端统一为 10。保留本地主分支表格布局、参考图提及、删除列、结果定位、手工行增量同步及单行重新生成。 - [x] 合入 PR #549 的图片 / 视频列表分析、动态表格、视频脚本转换和批量生成设置;参考图上限前后端统一为 10。保留本地主分支表格布局、参考图提及、删除列、结果定位、手工行增量同步及单行重新生成。

View File

@@ -85,10 +85,15 @@
轮询时按 `status`、`state`、`data.status` 取状态,`completed`/`succeeded` 归一为 `succeeded`, 轮询时按 `status`、`state`、`data.status` 取状态,`completed`/`succeeded` 归一为 `succeeded`,
`failed`/`expired`/`error` 归一为 `failed`,其余按进行中处理。 `failed`/`expired`/`error` 归一为 `failed`,其余按进行中处理。
视频地址按 `metadata.url`、`metadata.video_url`、`video_url`、`videoUrl`、`result_url`、`url`、 视频地址优先取 `metadata.direct_url`、`data.metadata.direct_url`,再按
`metadata.url`、`metadata.video_url`、`video_url`、`videoUrl`、`result_url`、`url`、
`data.metadata.url`、`data.video_url`、`output.url` 依次取值;上游返回的是临时地址 `data.metadata.url`、`data.video_url`、`output.url` 依次取值;上游返回的是临时地址
(`resultEphemeral`),由宿主立即下载并转存为项目资源。 (`resultEphemeral`),由宿主立即下载并转存为项目资源。
H3 工作流完成响应可能同时包含相对路径 `metadata.url` 和完整 HTTPS 地址
`metadata.direct_url`,必须优先选择后者,避免把缺少主机的路径交给外部资源下载器。
下载仍执行宿主的地址与 SSRF 校验。
错误信息取 `error.message`、`message`、`fail_reason`,错误码路径为 `error.message` 与 `error.code`。 错误信息取 `error.message`、`message`、`fail_reason`,错误码路径为 `error.message` 与 `error.code`。
HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结束并把上游原文回传给用户。 HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结束并把上游原文回传给用户。
@@ -102,7 +107,7 @@ HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结
"apiVersion": "yingce.plugin/v2", "apiVersion": "yingce.plugin/v2",
"id": "lxmone-video-suite", "id": "lxmone-video-suite",
"name": "万有引力视频套件", "name": "万有引力视频套件",
"version": "1.0.0", "version": "1.0.1",
"author": "Yingce / 万有引力", "author": "Yingce / 万有引力",
"description": "万有引力(lxmone.xyz)视频协议套件:Wan 3.0(现有渠道与 S 渠道)、Seedance 2 / 2.5、SD 2.0 / 2.5 / Mini、Grok Imagine、MiniMax H3(Max 与 A-E 独立工作流)。", "description": "万有引力(lxmone.xyz)视频协议套件:Wan 3.0(现有渠道与 S 渠道)、Seedance 2 / 2.5、SD 2.0 / 2.5 / Mini、Grok Imagine、MiniMax H3(Max 与 A-E 独立工作流)。",
"permissions": [ "permissions": [
@@ -360,6 +365,12 @@ HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结
}, },
"videos": { "videos": {
"$coalesce": [ "$coalesce": [
{
"$ref": "response.metadata.direct_url"
},
{
"$ref": "response.data.metadata.direct_url"
},
{ {
"$ref": "response.metadata.url" "$ref": "response.metadata.url"
}, },
@@ -648,6 +659,12 @@ HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结
}, },
"videos": { "videos": {
"$coalesce": [ "$coalesce": [
{
"$ref": "response.metadata.direct_url"
},
{
"$ref": "response.data.metadata.direct_url"
},
{ {
"$ref": "response.metadata.url" "$ref": "response.metadata.url"
}, },
@@ -912,6 +929,12 @@ HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结
}, },
"videos": { "videos": {
"$coalesce": [ "$coalesce": [
{
"$ref": "response.metadata.direct_url"
},
{
"$ref": "response.data.metadata.direct_url"
},
{ {
"$ref": "response.metadata.url" "$ref": "response.metadata.url"
}, },
@@ -1176,6 +1199,12 @@ HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结
}, },
"videos": { "videos": {
"$coalesce": [ "$coalesce": [
{
"$ref": "response.metadata.direct_url"
},
{
"$ref": "response.data.metadata.direct_url"
},
{ {
"$ref": "response.metadata.url" "$ref": "response.metadata.url"
}, },
@@ -1440,6 +1469,12 @@ HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结
}, },
"videos": { "videos": {
"$coalesce": [ "$coalesce": [
{
"$ref": "response.metadata.direct_url"
},
{
"$ref": "response.data.metadata.direct_url"
},
{ {
"$ref": "response.metadata.url" "$ref": "response.metadata.url"
}, },
@@ -1721,6 +1756,12 @@ HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结
}, },
"videos": { "videos": {
"$coalesce": [ "$coalesce": [
{
"$ref": "response.metadata.direct_url"
},
{
"$ref": "response.data.metadata.direct_url"
},
{ {
"$ref": "response.metadata.url" "$ref": "response.metadata.url"
}, },
@@ -2013,6 +2054,12 @@ HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结
}, },
"videos": { "videos": {
"$coalesce": [ "$coalesce": [
{
"$ref": "response.metadata.direct_url"
},
{
"$ref": "response.data.metadata.direct_url"
},
{ {
"$ref": "response.metadata.url" "$ref": "response.metadata.url"
}, },
@@ -2347,6 +2394,12 @@ HTTP 失败、业务错误码或状态进入 `failed` 时,任务以失败结
}, },
"videos": { "videos": {
"$coalesce": [ "$coalesce": [
{
"$ref": "response.metadata.direct_url"
},
{
"$ref": "response.data.metadata.direct_url"
},
{ {
"$ref": "response.metadata.url" "$ref": "response.metadata.url"
}, },

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,26 @@
import { modelCapabilityConfigFor, normalizeVideoValue } from "@/lib/model-capabilities";
import { resolveModelVideoBooleanOptions } from "@/lib/model-selection";
import type { AiConfig } from "@/stores/use-config-store";
import type { CreationSettings } from "./creation-types";
// 创作台没有音频和水印开关;报价与提交共用所选模型的默认值和能力限制。
export function creationVideoConfig(config: AiConfig, model: string, settings: Pick<CreationSettings, "ratio" | "seconds" | "videoQuality">): AiConfig {
const profile = modelCapabilityConfigFor(config, model).video!;
const normalized = normalizeVideoValue(profile, {
ratio: settings.ratio,
seconds: settings.seconds,
resolution: settings.videoQuality,
});
return {
...config,
model,
videoModel: model,
size: normalized.ratio,
videoSeconds: normalized.seconds,
vquality: normalized.resolution.replace(/p$/i, ""),
...resolveModelVideoBooleanOptions(config, model, {}, {
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
}),
};
}

View File

@@ -34,6 +34,7 @@ import { attachCreationTaskContexts, completedCreationGenerationTask, conversati
import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace"; import { CreationComposer, CreationEmptySuggest, CreationFeaturedWorks, CreationHistoryDrawer, CreationMessageView, CreationModeTabs, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace";
import { CreationAgentEntry } from "./creation-agent-entry"; import { CreationAgentEntry } from "./creation-agent-entry";
import { createCreationSubmitGate } from "./creation-submit-gate"; import { createCreationSubmitGate } from "./creation-submit-gate";
import { creationVideoConfig } from "./creation-generation-config";
const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal }))); const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal })));
const loadCreationRuntime = () => import("./creation-runtime"); const loadCreationRuntime = () => import("./creation-runtime");
@@ -142,10 +143,13 @@ export default function CreatePage() {
options: mode === "image" options: mode === "image"
? { size: ratio, quality, count: Number(count), transparentBackground: config.transparentBackground === "true" } ? { size: ratio, quality, count: Number(count), transparentBackground: config.transparentBackground === "true" }
: mode === "video" : mode === "video"
? { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" } ? { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality }
: {}, : {},
}), [attachments, config.transparentBackground, config.videoGenerateAudio, config.videoWatermark, count, hasPrompt, mode, quality, ratio, seconds, videoQuality]); }), [attachments, config.transparentBackground, count, hasPrompt, mode, quality, ratio, seconds, videoQuality]);
const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel; const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel;
const generationConfig = useMemo(() => mode === "video"
? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality })
: config, [config, mode, ratio, seconds, selectedModel, videoQuality]);
const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]); const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]);
const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]); const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]);
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6;
@@ -628,18 +632,15 @@ export default function CreatePage() {
const requestLifecycle = runtime.beginGenerationConsumer(controller.signal); const requestLifecycle = runtime.beginGenerationConsumer(controller.signal);
abortRef.current = controller; abortRef.current = controller;
const normalizedImage = mode === "image" ? normalizeImageValue(imageProfile, { size: ratio, quality, count }) : undefined; const normalizedImage = mode === "image" ? normalizeImageValue(imageProfile, { size: ratio, quality, count }) : undefined;
const normalizedVideo = mode === "video" ? normalizeVideoValue(videoProfile, { seconds, ratio, resolution: videoQuality }) : undefined;
const requestConfig = { const requestConfig = {
...config, ...generationConfig,
model: selectedModel, model: selectedModel,
imageModel: selectedModel, imageModel: selectedModel,
videoModel: selectedModel, videoModel: selectedModel,
textModel: selectedModel, textModel: selectedModel,
...(mode === "image" ...(mode === "image"
? { size: normalizedImage?.size || ratio, quality: normalizedImage?.quality || quality, count: normalizedImage?.count || count, videoSeconds: config.videoSeconds } ? { size: normalizedImage?.size || ratio, quality: normalizedImage?.quality || quality, count: normalizedImage?.count || count, videoSeconds: config.videoSeconds }
: mode === "video" : {}),
? { size: normalizedVideo?.ratio ?? ratio, videoSeconds: normalizedVideo?.seconds || seconds, vquality: (normalizedVideo?.resolution ?? videoQuality).replace(/p$/i, "") }
: {}),
}; };
try { try {
if (mode === "text") { if (mode === "text") {
@@ -957,7 +958,7 @@ export default function CreatePage() {
modelRequirements, modelRequirements,
imageProfile, imageProfile,
videoProfile, videoProfile,
config, config: generationConfig,
onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value), onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value),
ratio, ratio,
setRatio: setComposerRatio, setRatio: setComposerRatio,

View File

@@ -0,0 +1,50 @@
import { expect, test } from "bun:test";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import { modelQuoteRequest } from "../src/lib/model-pricing";
import { creationVideoConfig } from "../src/pages/create/creation-generation-config";
import { prepareBackendGenerationTask } from "../src/services/api/generation-task";
import { defaultConfig, type AiConfig } from "../src/stores/use-config-store";
function videoConfig(generateAudio: { supported: boolean; default: boolean }): AiConfig {
const capabilityConfig = defaultModelCapabilityConfig();
capabilityConfig.video!.generateAudio = generateAudio;
capabilityConfig.video!.watermark = { supported: false, default: false };
return {
...defaultConfig,
model: "gravity::minimax-h3-a",
videoModel: "gravity::minimax-h3-a",
videoGenerateAudio: "true",
videoWatermark: "true",
channels: [{
id: "gravity", name: "万有引力", baseUrl: "/api", apiKey: "", apiFormat: "openai", scope: "system",
models: ["minimax-h3-a"],
modelCosts: [{ model: "minimax-h3-a", capability: "video", billingMode: "per_second", unitPriceMicrocredits: 1, capabilityConfig }],
}],
};
}
test("创作台无声视频报价、提交与重试不携带全局音频和水印默认值", async () => {
const config = videoConfig({ supported: false, default: false });
const requestConfig = creationVideoConfig(config, config.videoModel, { ratio: "16:9", seconds: "6", videoQuality: "480" });
const quote = modelQuoteRequest(requestConfig, requestConfig.model, "video");
expect(quote?.intent.options).toMatchObject({ size: "16:9", vquality: "480", videoSeconds: 6, videoGenerateAudio: false, videoWatermark: false });
for (const retryOf of [undefined, "failed-task"]) {
const task = await prepareBackendGenerationTask({ mode: "video", prompt: "小猫在流泪", config: requestConfig, retryOf });
expect(task.input).toMatchObject({
config: { size: "16:9", vquality: "480", videoSeconds: "6", videoGenerateAudio: "false", videoWatermark: "false" },
...(retryOf ? { metadata: { retryOf } } : {}),
});
expect(task.operation).toBe("text_to_video");
}
expect(config.videoGenerateAudio).toBe("true");
expect(config.videoWatermark).toBe("true");
});
test.each([false, true])("创作台支持同步音频时遵循模型默认值 %s", (audioDefault) => {
const config = videoConfig({ supported: true, default: audioDefault });
config.videoGenerateAudio = String(!audioDefault);
const requestConfig = creationVideoConfig(config, config.videoModel, { ratio: "16:9", seconds: "6", videoQuality: "480" });
expect(requestConfig.videoGenerateAudio).toBe(String(audioDefault));
expect(modelQuoteRequest(requestConfig, requestConfig.model, "video")?.intent.options?.videoGenerateAudio).toBe(audioDefault);
});