fix(tvstar): validate live video contracts and avoid duplicate planning
This commit is contained in:
parent
570375b5f1
commit
88cf96b998
@ -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") {
|
||||
|
||||
@ -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]) => <button className="flex h-10 w-full items-center justify-between rounded-xl px-3 text-left text-[13px] text-white/60 hover:bg-white/5 hover:text-white" key={label as string} onClick={action as () => void}><span><Sparkles className="mr-2 inline size-3.5" />{label as string}</span><span>→</span></button>)}</div>
|
||||
</div> : <div className="space-y-4 pt-4">
|
||||
{(active.messages || []).length ? <div aria-label="TVstar 对话记录" className="space-y-2">{active.messages?.map(item => <p className={`max-w-[92%] whitespace-pre-wrap rounded-xl px-3 py-2 text-xs leading-5 ${item.role === "user" ? "ml-auto bg-[#343b48] text-white" : "bg-[#292929] text-white/70"}`} key={item.id}>{item.content}</p>)}</div> : null}
|
||||
{active.groupId ? <div aria-label="TVstar 制作进度" className={`${panel} p-3`}><p className="text-xs text-cyan-200" role="status">{productionStatus}</p>{!active.preparedOnly ? <p className="mt-1 text-[11px] text-white/45">{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成</p> : <p className="mt-1 text-[11px] text-white/45">画布节点已保存,尚未启动模型或扣费</p>}{finalUrl ? <video className="mt-3 w-full rounded-lg bg-black" controls playsInline preload="metadata" src={finalUrl} /> : null}{finalTask?.error ? <p className="mt-2 text-xs text-red-200">{finalTask.error}</p> : null}</div> : null}
|
||||
{active.groupId ? <div aria-label="TVstar 制作进度" className={`${panel} p-3`}><p className="text-xs text-cyan-200" role="status">{productionStatus}</p>{!active.preparedOnly ? <p className="mt-1 text-[11px] text-white/45">{activeTasks.filter(task => task.status === "succeeded").length}/{activeTasks.length || "…"} 项任务完成</p> : <p className="mt-1 text-[11px] text-white/45">画布节点已保存,尚未启动模型或扣费</p>}{finalUrl ? <video className="mt-3 w-full rounded-lg bg-black" controls playsInline preload="metadata" src={finalUrl} /> : null}{failedTask?.error ? <p className="mt-2 text-xs text-red-200" role="alert">{failedTask.request.node.title}:{failedTask.error}</p> : null}</div> : null}
|
||||
<div className={`${panel} p-3`}><p className="mb-2 text-[11px] text-white/40">创作目标</p><p className="whitespace-pre-wrap text-[13px] leading-6">{active.goal}</p></div>
|
||||
{active.plan ? <div className={`${panel} p-4`}><div className="mb-3 flex items-center gap-2 text-[13px] font-medium"><Sparkles className="size-4 text-cyan-300" />创作规划</div><p className="mb-3 text-[13px] leading-6 text-white/75">{active.plan.summary}</p><ol className="space-y-2">{active.plan.agentPlan.map((step, index) => <li className="flex gap-2 text-xs leading-5 text-white/70" key={`${index}-${step}`}><span className="text-cyan-300">{String(index + 1).padStart(2, "0")}</span><span>{step}</span></li>)}</ol>{active.plan.deliverables.length ? <p className="mt-4 border-t border-white/10 pt-3 text-[11px] text-white/45">交付物 · {active.plan.deliverables.join(" / ")}</p> : null}</div> : null}
|
||||
{active.shots?.length ? <div className="space-y-2"><h3 className="text-[13px] font-medium">分镜脚本 · {active.shots.length} 镜头</h3>{active.shots.map((shot, index) => <article className={`${panel} p-3`} key={`${index}-${shot.shot}`}><div className="mb-1 flex items-center justify-between text-xs"><span className="text-white/90">镜头 {shot.shot} · {shot.scene || "场景"}</span><span className="text-white/40">{shot.duration}</span></div><p className="text-xs leading-5 text-white/60">{shot.sceneDescription}</p><p className="mt-2 text-[11px] text-cyan-200/70">{shot.shotSize} · {shot.cameraMovement}</p></article>)}</div> : null}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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 : [];
|
||||
|
||||
47
lib/ai/vip98k-video-parameters.ts
Normal file
47
lib/ai/vip98k-video-parameters.ts
Normal file
@ -0,0 +1,47 @@
|
||||
function object(value: unknown): Record<string, any> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
|
||||
}
|
||||
|
||||
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<string, unknown>, 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("供应商当前清晰度不支持所选视频时长,请调整镜头时长");
|
||||
}
|
||||
}
|
||||
@ -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)<Math.abs(best-seconds)?value:best,options.durations[0]);
|
||||
const durations = durationsFor(model);
|
||||
if (!durations.length) throw Error("视频模型当前清晰度没有可执行的时长规格");
|
||||
const duration = requirements.minimumDuration ? durations.find(value => value >= seconds)! : durations.reduce((best,value)=>Math.abs(value-seconds)<Math.abs(best-seconds)?value:best,durations[0]);
|
||||
return {mode,model:model.id||model.modelId||"",duration,
|
||||
ratio:requirements.ratio ? (options.ratios.includes(requirements.ratio) ? requirements.ratio : options.ratios.find(value => 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};
|
||||
}
|
||||
|
||||
@ -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<string, { expires: number; pending: Promise<ReturnType<typeof extractPublicCatalogModels>> }>();
|
||||
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;
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<string, unknown>) => ({ 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", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user