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} + {active.groupId ?

{productionStatus}

{!active.preparedOnly ?

{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成

:

画布节点已保存,尚未启动模型或扣费

}{finalUrl ?
: null}

创作目标

{active.goal}

{active.plan ?
创作规划

{active.plan.summary}

    {active.plan.agentPlan.map((step, index) =>
  1. {String(index + 1).padStart(2, "0")}{step}
  2. )}
{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", () => {