diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx
index 6b7eb6d1a..8aec6440f 100644
--- a/components/canvas/LiblibVideoCanvasReplica.tsx
+++ b/components/canvas/LiblibVideoCanvasReplica.tsx
@@ -5590,7 +5590,7 @@ export function LiblibVideoCanvasReplica({
throw new Error("所选视频模型不支持首帧生视频,请换一个模型");
videoSettings = shots.map((_shot, index) => chooseStoryVideoSettings(
availableModels, true, timing.shotDurations[index], preferredModel,
- { ...output, minimumDuration: true, strictModel: Boolean(selection.videoModelRef) },
+ { ...output, minimumDuration: true, minimumResolution: true, strictModel: Boolean(selection.videoModelRef) },
));
} catch (error) {
if (runMode === "submit") {
diff --git a/components/canvas/tvstar/TVstarPanel.tsx b/components/canvas/tvstar/TVstarPanel.tsx
index 04a016a92..566253cb6 100644
--- a/components/canvas/tvstar/TVstarPanel.tsx
+++ b/components/canvas/tvstar/TVstarPanel.tsx
@@ -89,6 +89,12 @@ function isShot(value: unknown): value is TVstarShot {
return Boolean(value && typeof value === "object" && typeof (value as TVstarShot).sceneDescription === "string");
}
+function productionShots(value: unknown) {
+ const shots = Array.isArray(value) ? value.filter(isShot) : [];
+ if (shots.length > 12) throw Error("自动视频制作最多支持12个镜头,请拆分制作;不会截掉多余镜头");
+ return shots;
+}
+
export function TVstarPanel({
canvasId,
models,
@@ -175,6 +181,7 @@ export function TVstarPanel({
const active = sessions.find(item => item.id === activeId);
const activeTasks = active?.groupId ? tasks.filter(task => task.request.workflow?.groupId === active.groupId) : [];
const finalTask = activeTasks.find(task => task.request.kind === "smart-edit" && task.request.params.operation === "render");
+ const failedTask = activeTasks.find(task => (task.status === "failed" || task.status === "interrupted") && task.error && !task.error.startsWith("上游任务失败")) || finalTask;
const finalUrl = typeof finalTask?.result?.url === "string" ? finalTask.result.url : "";
const productionStatus = active?.preparedOnly ? "待运行工作流已建好,检查后可开始制作"
: activeTasks.some(task => task.status === "failed" || task.status === "interrupted")
@@ -365,7 +372,7 @@ export function TVstarPanel({
}
setError("");
if (active) { await reviseSession(active, input); return; }
- const directShots = mode === "script" || mode === "storyboard";
+ const directShots = mode === "script" || mode === "storyboard" || mode === "full";
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(input);
setBusy(mode === "edit" ? "submit" : directShots ? "shots" : "plan");
let item: Session = {
@@ -396,13 +403,14 @@ export function TVstarPanel({
return;
}
const data = await runAnalysis(directShots ? "story-script" : "agent-plan", input, item.references || [], item.nodeContexts || []);
- const shots = directShots && Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : undefined;
+ const shots = directShots ? productionShots(data.analysisRows) : undefined;
if (directShots && !shots?.length) throw new Error("模型没有返回有效分镜,请调整创作目标后重试");
item = {
...item,
- plan: directShots ? undefined : {
+ plan: directShots && mode !== "full" ? undefined : {
summary: String(data.summary || ""),
- agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String) : [],
+ agentPlan: Array.isArray(data.agentPlan) ? data.agentPlan.map(String)
+ : Array.isArray(data.recommendedNextSteps) ? data.recommendedNextSteps.map(String) : [],
deliverables: Array.isArray(data.deliverables) ? data.deliverables.map(String) : [],
recommendedNodes: Array.isArray(data.recommendedNodes) ? data.recommendedNodes.map(String) : [],
model: typeof data.model === "string" ? data.model : undefined,
@@ -424,9 +432,9 @@ export function TVstarPanel({
}
if (mode === "full") {
setBusy("shots");
- const storyboard = await runAnalysis("story-script", [input, item.plan?.summary,
+ const storyboard = item.shots?.length ? { analysisRows: item.shots } : await runAnalysis("story-script", [input, item.plan?.summary,
item.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), item.references || [], item.nodeContexts || []);
- const generated = Array.isArray(storyboard.analysisRows) ? storyboard.analysisRows.filter(isShot).slice(0, 12) : [];
+ const generated = productionShots(storyboard.analysisRows);
if (!generated.length) throw new Error("模型没有返回有效分镜,请重试");
item = { ...item, shots: generated, pendingStage: allowAutoRun ? "submit" : undefined, messages: [
...(item.messages || []), message("assistant", allowAutoRun
@@ -510,7 +518,7 @@ export function TVstarPanel({
return;
}
const data = await runAnalysis("story-script", prompt, mergedRefs, mergedContexts);
- const shots = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : [];
+ const shots = productionShots(data.analysisRows);
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试修改要求");
let updated: Session = { ...pending, shots, runSubmitted: false, pendingStage: session.mode === "full" && allowAutoRun ? "submit" : undefined,
messages: [...(pending.messages || []), message("assistant", `已按新要求更新 ${shots.length} 个分镜。`)] };
@@ -545,7 +553,7 @@ export function TVstarPanel({
try {
const data = await runAnalysis("story-script", [active.goal, active.plan?.summary,
active.plan?.agentPlan.join(";"), "每个镜头给出画面提示词、运镜、时长和声音建议。"].filter(Boolean).join("\n"), active.references || [], active.nodeContexts || []);
- const shots = Array.isArray(data.analysisRows) ? data.analysisRows.filter(isShot).slice(0, 12) : [];
+ const shots = productionShots(data.analysisRows);
if (!shots.length) throw new Error("模型没有返回有效分镜,请重试");
const latest = active.messages?.filter(item => item.role === "user").at(-1)?.content || active.goal;
const allowAutoRun = autoGenerate && tvstarAllowsGeneration(latest);
@@ -787,7 +795,7 @@ export function TVstarPanel({
].map(([label, action]) => )}
:
{(active.messages || []).length ?
{active.messages?.map(item =>
{item.content}
)}
: null}
- {active.groupId ?
{productionStatus}
{!active.preparedOnly ?
{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成
:
画布节点已保存,尚未启动模型或扣费
}{finalUrl ?
: null}{finalTask?.error ?
{finalTask.error}
: null}
: null}
+ {active.groupId ?
{productionStatus}
{!active.preparedOnly ?
{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成
:
画布节点已保存,尚未启动模型或扣费
}{finalUrl ?
: null}{failedTask?.error ?
{failedTask.request.node.title}:{failedTask.error}
: null}
: null}
{active.plan ?
创作规划
{active.plan.summary}
{active.plan.agentPlan.map((step, index) => - {String(index + 1).padStart(2, "0")}{step}
)}
{active.plan.deliverables.length ?
交付物 · {active.plan.deliverables.join(" / ")}
: null}
: null}
{active.shots?.length ?
分镜脚本 · {active.shots.length} 镜头
{active.shots.map((shot, index) =>
镜头 {shot.shot} · {shot.scene || "场景"}{shot.duration}
{shot.sceneDescription}
{shot.shotSize} · {shot.cameraMovement}
)}
: null}
diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts
index ef36308c8..b9e998393 100644
--- a/lib/ai/providers/vip98k.ts
+++ b/lib/ai/providers/vip98k.ts
@@ -1,3 +1,4 @@
+import { mapVip98kVideoParameters } from "../vip98k-video-parameters";
import {
ProviderTaskFailedError,
type ProviderTaskLifecycle,
@@ -1585,6 +1586,7 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation |
body.mode = params.imageUrls.length > 0 ? "image-to-video" : "text-to-video";
}
+ if (params.kind === "video") mapVip98kVideoParameters(body, invocation?.inputSchema);
return { body, imageBindingKeys, outputConstraintFallbacks };
}
diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts
index 6944a14c3..57f96cd23 100644
--- a/lib/ai/vip98k-models.ts
+++ b/lib/ai/vip98k-models.ts
@@ -1,3 +1,4 @@
+import { canonicalVip98kVideoRatio, vip98kVideoDurationBounds } from "./vip98k-video-parameters";
import {
hasVerifiedVip98kGptImage25ResolutionTiers,
hasVerifiedVip98kOpenAiImageArrayContract,
@@ -825,14 +826,18 @@ function createVideoPreset(
const capabilities = getCapabilities(model);
const inputModes = getInputModes(model);
const outputModes = getOutputModes(model);
- const supportedResolutions = getSupportedVideoResolutions(model);
- const durations = getDurations(model);
+ const invocation = getInvocationCandidates(model)[0];
+ const schema = toRecord(toRecord(invocation?.input_schema)?.properties) || {};
+ const schemaResolutions = parseStringArray(toRecord(schema.resolution)?.enum);
+ const supportedResolutions = schemaResolutions.length ? schemaResolutions.map(normalizeVideoResolution).filter((value): value is string => Boolean(value)) : getSupportedVideoResolutions(model);
+ const bounds = vip98kVideoDurationBounds(invocation?.input_schema);
+ const durations = bounds.durations || (bounds.min > 0 && bounds.max <= 120
+ ? Array.from({ length: Math.floor(bounds.max) - Math.ceil(bounds.min) + 1 }, (_, index) => Math.ceil(bounds.min) + index)
+ : getDurations(model));
const defaultDuration =
parseNumber(model.defaultDuration || model.default_duration) ||
durations[durations.length - 1] ||
8;
- const invocation = getInvocationCandidates(model)[0];
- const schema = toRecord(toRecord(invocation?.input_schema)?.properties) || {};
const keys = new Set(getInvocationRequestKeys(model));
const imageModes = parseStringArray(toRecord(schema.mode_image)?.enum);
const supportsReferenceImages = capabilities.includes("image-to-video") || keys.has("file-urls") || keys.has("image") || keys.has("image-url");
@@ -844,7 +849,7 @@ function createVideoPreset(
...(supportsFramePair ? ["first-last-frame"] : []),
...(supportsIngredients ? ["reference-images"] : []),
];
- const ratios = parseStringArray(toRecord(schema.aspect_ratio)?.enum);
+ const ratios = parseStringArray(toRecord(schema.aspect_ratio)?.enum).map(canonicalVip98kVideoRatio);
const maxReferenceImages = Number(toRecord(schema.file_urls)?.maxItems) || (supportsReferenceImages ? 1 : 0);
return {
@@ -1118,7 +1123,7 @@ function extractCatalogModels(payload: unknown): Vip98kCatalogModel[] {
return [];
}
-function extractPublicCatalogModels(payload: unknown): Vip98kCatalogModel[] {
+export function extractPublicCatalogModels(payload: unknown): Vip98kCatalogModel[] {
const record = toRecord(payload);
const data = toRecord(record?.data);
const sections = Array.isArray(data?.sections) ? data.sections : [];
diff --git a/lib/ai/vip98k-video-parameters.ts b/lib/ai/vip98k-video-parameters.ts
new file mode 100644
index 000000000..f1530ba8e
--- /dev/null
+++ b/lib/ai/vip98k-video-parameters.ts
@@ -0,0 +1,47 @@
+function object(value: unknown): Record
{
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {};
+}
+
+export function canonicalVip98kVideoRatio(value: string) {
+ return value === "landscape" ? "16:9" : value === "portrait" ? "9:16" : value;
+}
+
+export function vip98kVideoDurationBounds(schema: unknown, resolution?: string) {
+ let min = 0, max = Infinity;
+ let durations: number[] | undefined;
+ const visit = (value: unknown) => {
+ const current = object(value), properties = object(current.properties);
+ const field = object(properties.duration_seconds || properties.duration);
+ if (Number.isFinite(field.minimum)) min = Math.max(min, field.minimum);
+ if (Number.isFinite(field.maximum)) max = Math.min(max, field.maximum);
+ const allowed = Array.isArray(field.enum) ? field.enum : Number.isFinite(field.const) ? [field.const] : undefined;
+ if (allowed) {
+ const numbers = allowed.filter((n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n > 0);
+ durations = durations ? durations.filter(n => numbers.includes(n)) : numbers;
+ }
+ for (const child of Array.isArray(current.allOf) ? current.allOf : []) visit(child);
+ const condition = object(object(current.if).properties).resolution;
+ if (resolution && object(condition).const === resolution) visit(current.then);
+ };
+ visit(schema);
+ return { min, max, durations };
+}
+
+export function mapVip98kVideoParameters(body: Record, schema: unknown) {
+ const properties = object(object(schema).properties);
+ const ratios = object(properties.aspect_ratio).enum;
+ if (Array.isArray(ratios) && !ratios.includes(body.aspect_ratio)) {
+ const alias = body.aspect_ratio === "16:9" ? "landscape" : body.aspect_ratio === "9:16" ? "portrait" : undefined;
+ if (!alias || !ratios.includes(alias)) throw Error("供应商当前线路不支持所选视频比例");
+ body.aspect_ratio = alias;
+ }
+ const resolutions = object(properties.resolution).enum;
+ if (body.resolution && Array.isArray(resolutions) && !resolutions.includes(body.resolution))
+ throw Error("供应商当前线路不支持所选视频清晰度,请同步模型规格后重试");
+ const seconds = Number(body.duration_seconds ?? body.duration);
+ if (seconds) {
+ const bounds = vip98kVideoDurationBounds(schema, String(body.resolution || ""));
+ if (seconds < bounds.min || seconds > bounds.max || (bounds.durations && !bounds.durations.includes(seconds)))
+ throw Error("供应商当前清晰度不支持所选视频时长,请调整镜头时长");
+ }
+}
diff --git a/lib/video-canvas/model-matching.ts b/lib/video-canvas/model-matching.ts
index 92f56a865..e5fc35f25 100644
--- a/lib/video-canvas/model-matching.ts
+++ b/lib/video-canvas/model-matching.ts
@@ -5,6 +5,7 @@ import {
} from "@/lib/ai/model-classification";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
+import { vip98kVideoDurationBounds } from "@/lib/ai/vip98k-video-parameters";
import type { VideoGenerationMode } from "@/types/ai";
export type CanvasModelKind = "image" | "image-edit" | "video" | "audio-video" | "multimodal" | "text" | "any";
@@ -114,6 +115,7 @@ export type StoryVideoRequirements = {
resolution?: string;
generateAudio?: boolean;
minimumDuration?: boolean;
+ minimumResolution?: boolean;
strictModel?: boolean;
};
@@ -121,13 +123,25 @@ export function chooseStoryVideoSettings(models: CanvasModelDescriptor[], hasIma
const mode:VideoGenerationMode = hasImage ? "first-frame" : "text-to-video";
const choices = filterCanvasModels(models,"video",mode);
const preferred = choices.find(item=>item.id===preferredRef||item.modelId===preferredRef);
+ const selectResolution = (values: string[]) => requirements.minimumResolution && requirements.resolution
+ ? values.filter(value => [480, 720, 768, 1080].includes(Number.parseInt(value)) && Number.parseInt(value) >= Number.parseInt(requirements.resolution!))
+ .sort((a, b) => Number.parseInt(a) - Number.parseInt(b))[0]
+ : requirements.resolution;
+ const durationsFor = (candidate: CanvasModelDescriptor) => {
+ const values = getCanvasVideoParameters(candidate, mode);
+ const resolution = selectResolution(values.resolutions) || (values.resolutions.includes("720p") ? "720p" : values.resolutions[0]);
+ const bounds = candidate.providerType === "vip98k"
+ ? vip98kVideoDurationBounds(record(record(candidate.capabilities).vip98k).invocation?.inputSchema, resolution)
+ : { min: 0, max: Infinity, durations: undefined };
+ return values.durations.filter(value => value >= bounds.min && value <= bounds.max && (!bounds.durations || bounds.durations.includes(value)));
+ };
if (requirements.strictModel && preferredRef && !preferred)
throw Error("所选视频模型不支持当前生成模式,请重新选择");
const meetsRequirements = (candidate: CanvasModelDescriptor) => {
const values = getCanvasVideoParameters(candidate, mode);
- return (!requirements.minimumDuration || values.durations.some(value => value >= seconds)) &&
+ return (!requirements.minimumDuration || durationsFor(candidate).some(value => value >= seconds)) &&
(!requirements.ratio || values.ratios.some(value => value === requirements.ratio || value === "adaptive" || value === "auto")) &&
- (!requirements.resolution || values.resolutions.some(value => value.toLowerCase() === requirements.resolution!.toLowerCase())) &&
+ (!requirements.resolution || (requirements.minimumResolution ? Boolean(selectResolution(values.resolutions)) : values.resolutions.some(value => value.toLowerCase() === requirements.resolution!.toLowerCase()))) &&
(requirements.generateAudio !== true || values.supportsAudio);
};
if (requirements.strictModel && preferred && !meetsRequirements(preferred))
@@ -135,10 +149,12 @@ export function chooseStoryVideoSettings(models: CanvasModelDescriptor[], hasIma
const model = (preferred && meetsRequirements(preferred) ? preferred : undefined) || choices.find(meetsRequirements);
if (!model) throw new Error(hasImage ? "没有可用的首帧视频模型" : "镜头缺少首帧,且没有可用的文生视频模型");
const options = getCanvasVideoParameters(model,mode);
- const executableResolutions = options.resolutions.filter(value => ["480p", "720p", "1080p"].includes(value.toLowerCase()));
+ const executableResolutions = options.resolutions.filter(value => ["480p", "720p", "768p", "1080p"].includes(value.toLowerCase()));
if (!options.durations.length || !options.ratios.length || !executableResolutions.length) throw new Error("视频模型缺少已确认且可执行的时长、比例或清晰度规格,请先完善模型配置");
- const duration = requirements.minimumDuration ? options.durations.find(value => value >= seconds)! : options.durations.reduce((best,value)=>Math.abs(value-seconds) value >= seconds)! : durations.reduce((best,value)=>Math.abs(value-seconds) value === "adaptive" || value === "auto")!) : options.ratios.includes("16:9")?"16:9":options.ratios[0],
- resolution:requirements.resolution ? requirements.resolution.toLowerCase() : executableResolutions.includes("720p")?"720p":executableResolutions[0],generateAudio:requirements.generateAudio ?? options.supportsAudio};
+ resolution:requirements.resolution ? selectResolution(options.resolutions)!.toLowerCase() : executableResolutions.includes("720p")?"720p":executableResolutions[0],generateAudio:requirements.generateAudio ?? options.supportsAudio};
}
diff --git a/lib/video-canvas/production-services.ts b/lib/video-canvas/production-services.ts
index 806a699e3..2e3fc0ec3 100644
--- a/lib/video-canvas/production-services.ts
+++ b/lib/video-canvas/production-services.ts
@@ -9,13 +9,46 @@ import { remoteAudioConfig } from "./smart-edit/remote-audio";
import { upscaleConfig } from "./video-upscale-remote";
import { assertCanvasProductionServices } from "./production-preflight";
import type { CanvasTaskRequest } from "./task-contract";
+import { extractPublicCatalogModels, normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models";
+import { mapVip98kVideoParameters } from "@/lib/ai/vip98k-video-parameters";
+
+const videoContracts = new Map> }>();
+async function publishedVideoContracts(base: string) {
+ const key = normalizeVip98kBaseUrl(base);
+ const cached = videoContracts.get(key);
+ if (cached && cached.expires > Date.now()) return cached.pending;
+ const pending = fetch(key + "/api/v2/catalog/models", { cache: "no-store", signal: AbortSignal.timeout(5000), redirect: "error" })
+ .then(async response => {
+ if (!response.ok) throw Error("无法核验供应商视频线路");
+ return extractPublicCatalogModels(await response.json());
+ });
+ videoContracts.set(key, { expires: Date.now() + 60_000, pending });
+ try { return await pending; }
+ catch (error) { videoContracts.delete(key); throw error; }
+}
const execute = promisify(execFile);
export async function preflightCanvasProductionRequests(requests: CanvasTaskRequest[]) {
const semanticRequests = requests.filter(request => request.kind === "smart-edit" && request.params.semantic === true);
+ const videoRequests = requests.filter(request => request.kind === "video");
+ const enabledModels = semanticRequests.length || videoRequests.length ? await getEnabledAiModelsWithProvider(false) : [];
+ for (const request of videoRequests) {
+ const ref = request.params.preferredModelRef || request.params.model;
+ const selected = enabledModels.find(item => item.model.id === ref || item.model.modelId === ref);
+ if (selected?.provider.type !== "vip98k") continue;
+ try {
+ const catalog = await publishedVideoContracts(selected.provider.baseUrl);
+ const current = catalog.find(item => item.id === selected.model.modelId);
+ if (!current?.invocation?.input_schema) throw Error("当前视频线路没有可核验的规格");
+ mapVip98kVideoParameters({ aspect_ratio: request.params.ratio, resolution: request.params.resolution,
+ duration_seconds: request.params.duration }, current.invocation.input_schema);
+ } catch (error) {
+ throw Object.assign(Error(`视频模型线路检查未通过:${error instanceof Error ? error.message : "规格不可用"};本次未启动生成,请同步模型配置或选择其他模型`), { status: 503, code: "CANVAS_SERVICE_UNAVAILABLE" });
+ }
+ }
if (semanticRequests.length) {
- const models = (await getEnabledAiModelsWithProvider(false)).filter(item =>
+ const models = enabledModels.filter(item =>
isCanvasVisionModel({ ...item.model, providerType: item.provider.type }));
for (const request of semanticRequests) {
const ref = request.params.preferredModelRef;
diff --git a/lib/video-canvas/tvstar-timing.ts b/lib/video-canvas/tvstar-timing.ts
index f4d424e7f..52b532a16 100644
--- a/lib/video-canvas/tvstar-timing.ts
+++ b/lib/video-canvas/tvstar-timing.ts
@@ -2,7 +2,7 @@ export function tvstarTiming(goal: string, shots: Array<{ duration: string }>) {
if (!shots.length) throw new Error("没有可生成的分镜");
const duration = /(\d+(?:\.\d+)?)\s*(秒钟|秒|s(?:ec(?:onds?)?)?|分钟|分|min(?:utes?)?)(?![a-z])/gi;
const requests = goal.split(/[,,。;;\n]/).flatMap(part => {
- if (/(?:镜头\s*[0-9一二三四五六七八九十]+|片段\s*[0-9一二三四五六七八九十]+|单段|每段|每个镜头|关键帧\s*[0-9一二三四五六七八九十]*)/.test(part)
+ if (/(?:镜头\s*[0-9一二三四五六七八九十]+|片段\s*[0-9一二三四五六七八九十]+|(?:镜头|片段)[^,,。;;\n]*各|单段|每段|每个镜头|each\s+(?:shot|clip)|per\s+(?:shot|clip)|(?:shot|clip)\s+\d+|关键帧\s*[0-9一二三四五六七八九十]*)/i.test(part)
&& !/(?:总时长|整体时长|整片|全片|成片时长|片长)/.test(part)) return [];
return [...part.matchAll(duration)].map(match => {
const value = Number(match[1]);
diff --git a/scripts/qa/video-render-service.cjs b/scripts/qa/video-render-service.cjs
index 279e66a24..f615e9356 100644
--- a/scripts/qa/video-render-service.cjs
+++ b/scripts/qa/video-render-service.cjs
@@ -136,6 +136,33 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
);
assert.ok(Math.abs(Number(probe.format.duration) - 3.5) < 0.15);
assert.equal(probe.streams[0].width, 1280);
+ const silentPlan = {
+ ...plan, ratio: "9:16", resolution: "480p", transition: 0,
+ captions: [], subtitles: false, music: undefined,
+ clips: plan.clips.map((clip, index) => ({ ...clip, sourceIn: 0, sourceOut: 1.6e6, speed: 1, muted: true,
+ transition: index ? { type: "fade", duration: 0.2, alignment: "center" } : undefined })),
+ };
+ const silentId = sha("portrait-silent-centered-transition"), silentInput = { id: silentId, tenant, plan: silentPlan };
+ assert.equal((await request("/v1/renders", silentInput)).status, 202);
+ let silentState;
+ for (let i = 0; i < 300; i++) {
+ silentState = (await request("/v1/renders/" + silentId)).body;
+ if (["succeeded", "failed"].includes(silentState.status)) break;
+ await sleep(200);
+ }
+ assert.equal(silentState.status, "succeeded", JSON.stringify(silentState));
+ const silentResponse = await fetch(base + "/v1/renders/" + silentId + "/video", { headers: { authorization: "Bearer " + token } });
+ const silentFile = path.join(data, "portrait-silent.mp4");
+ fs.writeFileSync(silentFile, Buffer.from(await silentResponse.arrayBuffer()));
+ const silentProbe = JSON.parse(execFileSync("ffprobe", ["-v", "error", "-show_format", "-show_streams", "-of", "json", silentFile]));
+ assert.equal(silentProbe.streams[0].width, 480);
+ assert.equal(silentProbe.streams[0].height, 854);
+ assert.ok(Math.abs(Number(silentProbe.format.duration) - 3.2) < 0.1, "centered transition keeps fractional requested length");
+ const volume = require("node:child_process").spawnSync("ffmpeg", ["-i", silentFile, "-af", "volumedetect", "-vn", "-f", "null", "-"], { encoding: "utf8" });
+ assert.equal(volume.status, 0);
+ const maximum = volume.stderr.match(/max_volume:\s*(-?[\d.]+) dB/);
+ assert.ok(maximum && Number(maximum[1]) < -80, "source music must not leak into silent output");
+ console.log("PASS: real portrait render, centered transitions, fractional delivery duration, silent final audio");
assert.equal((await request("/v1/renders", input)).body.attempts, 2);
await stop();
await start();
diff --git a/tests/routes/video-canvas-production.test.ts b/tests/routes/video-canvas-production.test.ts
index 41da3a76c..c575329a2 100644
--- a/tests/routes/video-canvas-production.test.ts
+++ b/tests/routes/video-canvas-production.test.ts
@@ -6,11 +6,35 @@ import { assertCanvasProductionServices } from "@/lib/video-canvas/production-pr
import type { CanvasTaskRequest } from "@/lib/video-canvas/task-contract";
import { HighlightValidationError, validateSequenceHighlights } from "@/lib/video-canvas/smart-edit/highlight-contract";
import { buildEditPlans, smartEditRequestSchema } from "@/lib/video-canvas/smart-edit/contract";
+import { mapVip98kVideoParameters } from "@/lib/ai/vip98k-video-parameters";
+import { buildVip98kModelPresets } from "@/lib/ai/vip98k-models";
const video = { id: "video", providerType: "atlascloud", modelId: "bytedance/seedance-2.0-mini/image-to-video",
capabilities: { kind: "video", supportedModes: ["first-frame"], durationOptions: [5, 10],
supportedRatios: ["16:9", "9:16"], supportedResolutions: ["720p", "1080p"], supportGenerateAudio: true } };
const request = (kind: CanvasTaskRequest["kind"], params: Record) => ({ kind, params } as CanvasTaskRequest);
+const h3Schema = { type: "object", properties: { prompt: { type: "string" }, file_urls: { type: "array" },
+ aspect_ratio: { enum: ["landscape", "portrait"] }, resolution: { enum: ["480p", "768p", "1080p"] },
+ duration_seconds: { minimum: 1, maximum: 15 } }, allOf: [
+ { if: { properties: { resolution: { const: "1080p" } }, required: ["resolution"] },
+ then: { properties: { duration_seconds: { maximum: 10 } } } },
+ ] };
+
+test("published H3 contract overrides stale catalog specs and maps native portrait input", () => {
+ const preset = buildVip98kModelPresets([{ id: "minimax-h3", type: "video", status: "published", available: true,
+ supported_resolutions: ["720p"], durations: [4, 5], invocation: { kind: "task", path: "/extensions/v1/tasks",
+ request_example: { model: "minimax-h3", prompt: "test" }, input_schema: h3Schema } }])[0];
+ const model = { ...preset, id: "h3", providerType: "vip98k" };
+ const settings = chooseStoryVideoSettings([model], true, 3, "h3", { ratio: "9:16", resolution: "720p", minimumResolution: true,
+ minimumDuration: true, strictModel: true });
+ expect(settings).toMatchObject({ ratio: "9:16", resolution: "768p", duration: 3 });
+ const body = { aspect_ratio: settings.ratio, resolution: settings.resolution, duration_seconds: settings.duration };
+ mapVip98kVideoParameters(body, h3Schema);
+ expect(body).toEqual({ aspect_ratio: "portrait", resolution: "768p", duration_seconds: 3 });
+ expect(() => mapVip98kVideoParameters({ aspect_ratio: "9:16", resolution: "720p", duration_seconds: 4 }, h3Schema)).toThrow("清晰度");
+ expect(() => chooseStoryVideoSettings([model], true, 11, "h3", { ratio: "9:16", resolution: "1080p", minimumResolution: true,
+ minimumDuration: true, strictModel: true })).toThrow("时长");
+});
test("TVstar carries portrait resolution and sound requirements through video settings", () => {
const output = tvstarOutputRequirements("制作 15 秒竖屏广告,1080P,配乐和音效,不要字幕");
@@ -56,6 +80,8 @@ test("delivery timing preserves fractional requirements and rejects unachievable
expect(timing.shotDurations.reduce((sum, duration) => sum + duration, 0)).toBeCloseTo(15.5);
expect(() => tvstarTiming("制作 10 分钟成片", [{ duration: "5s" }])).toThrow("300 秒");
expect(() => tvstarTiming("制作0.5秒片段", Array.from({ length: 4 }, () => ({ duration: "1s" })))).toThrow("不足");
+ expect(tvstarTiming("制作6秒广告,两个镜头各3秒", [{ duration: "3s" }, { duration: "3s" }])).toEqual({ targetDuration: 6, shotDurations: [3, 3] });
+ expect(tvstarTiming("Make a 6 second film, each shot 3 seconds", [{ duration: "3s" }, { duration: "3s" }]).targetDuration).toBe(6);
});
test("explicit video models cannot be replaced or used while ignoring unsupported sound", () => {