ai-chatbot/lib/ai/video-tool-capability.ts
Your Name 82c9173467
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(video): add capability-driven video tools contract
2026-03-05 11:42:54 +08:00

323 lines
9.5 KiB
TypeScript

import type { VideoModelCapabilities } from "@/types/ai";
import type {
VideoToolCapabilityContract,
VideoToolMode,
VideoToolModeOption,
VideoToolResolution,
} from "@/types/video-tool-capability";
const MODE_LABELS: Record<VideoToolMode, string> = {
"text-to-video": "文生视频",
"first-frame": "主体参考",
"first-last-frame": "首尾帧",
"reference-images": "智能多帧",
};
const MODE_ORDER: VideoToolMode[] = [
"text-to-video",
"first-last-frame",
"reference-images",
"first-frame",
];
const STANDARD_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
const PORTRAIT_RATIOS = ["16:9", "9:16", "1:1"];
const STANDARD_RESOLUTIONS: VideoToolResolution[] = ["480p", "720p", "1080p"];
const STANDARD_DURATIONS = [5, 10];
function asObject(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, any>) : null;
}
function normalizeDurations(min?: number, max?: number) {
const safeMin = Number.isFinite(min) ? Math.max(1, Math.round(Number(min))) : undefined;
const safeMax = Number.isFinite(max) ? Math.max(1, Math.round(Number(max))) : undefined;
if (!safeMin || !safeMax || safeMax < safeMin) {
return [...STANDARD_DURATIONS];
}
if (safeMax - safeMin <= 12) {
return Array.from({ length: safeMax - safeMin + 1 }, (_, index) => safeMin + index);
}
return [safeMin, safeMax];
}
function normalizeModeOptions(
modes: VideoToolMode[],
maxReferenceImages = 4
): VideoToolModeOption[] {
return MODE_ORDER.filter((mode) => modes.includes(mode)).map((mode) => {
if (mode === "text-to-video") {
return { mode, label: MODE_LABELS[mode], imageMin: 0, imageMax: 0 };
}
if (mode === "first-frame") {
return { mode, label: MODE_LABELS[mode], imageMin: 1, imageMax: 1 };
}
if (mode === "first-last-frame") {
return { mode, label: MODE_LABELS[mode], imageMin: 2, imageMax: 2 };
}
return {
mode,
label: MODE_LABELS[mode],
imageMin: 1,
imageMax: Math.max(1, Math.min(8, maxReferenceImages)),
};
});
}
function normalizeVideoCapabilities(rawCapabilities: unknown): Partial<VideoModelCapabilities> {
const raw = asObject(rawCapabilities);
if (!raw) return {};
const supportedModes = Array.isArray(raw.supportedModes)
? raw.supportedModes.filter((item): item is VideoToolMode =>
item === "text-to-video" ||
item === "first-frame" ||
item === "first-last-frame" ||
item === "reference-images"
)
: undefined;
const supportedRatios = Array.isArray(raw.supportedRatios)
? raw.supportedRatios
.map((item) => String(item || "").trim())
.filter(Boolean)
: undefined;
const supportedResolutions = Array.isArray(raw.supportedResolutions)
? raw.supportedResolutions.filter(
(item): item is VideoToolResolution => item === "480p" || item === "720p" || item === "1080p"
)
: undefined;
const durationRange = asObject(raw.durationRange);
const min = Number(durationRange?.min);
const max = Number(durationRange?.max);
return {
supportedModes,
supportedRatios: supportedRatios as any,
supportedResolutions: supportedResolutions as any,
durationRange:
Number.isFinite(min) && Number.isFinite(max)
? { min: Math.round(min), max: Math.round(max) }
: undefined,
maxReferenceImages: Number.isFinite(Number(raw.maxReferenceImages))
? Math.round(Number(raw.maxReferenceImages))
: undefined,
supportReturnLastFrame:
typeof raw.supportReturnLastFrame === "boolean" ? raw.supportReturnLastFrame : undefined,
supportSeed: typeof raw.supportSeed === "boolean" ? raw.supportSeed : undefined,
};
}
function createProviderDefaultCapability(providerType: string): VideoToolCapabilityContract {
if (providerType === "fal-video") {
return {
schemaVersion: "v1",
source: "provider-default",
providerType,
modeOptions: normalizeModeOptions(["first-frame", "first-last-frame"]),
ratioOptions: [...PORTRAIT_RATIOS],
durationOptions: Array.from({ length: 13 }, (_, index) => index + 3),
resolutionOptions: [],
ui: {
showResolution: false,
showFps: false,
showCameraMove: false,
showGenerateAudio: true,
showReturnLastFrame: true,
showSeed: true,
},
defaults: {
mode: "first-frame",
ratio: "16:9",
duration: 5,
generateAudio: true,
fps: 24,
},
};
}
if (providerType === "ruxa-video") {
return {
schemaVersion: "v1",
source: "provider-default",
providerType,
modeOptions: normalizeModeOptions(["first-frame"]),
ratioOptions: [...PORTRAIT_RATIOS],
durationOptions: [5, 10],
resolutionOptions: [],
ui: {
showResolution: false,
showFps: false,
showCameraMove: false,
showGenerateAudio: false,
showReturnLastFrame: false,
showSeed: false,
},
defaults: {
mode: "first-frame",
ratio: "16:9",
duration: 10,
generateAudio: false,
fps: 24,
},
};
}
if (providerType === "qianwen-video") {
return {
schemaVersion: "v1",
source: "provider-default",
providerType,
modeOptions: normalizeModeOptions(["first-frame"]),
ratioOptions: ["16:9"],
durationOptions: [5],
resolutionOptions: [],
ui: {
showResolution: false,
showFps: false,
showCameraMove: false,
showGenerateAudio: false,
showReturnLastFrame: false,
showSeed: false,
},
defaults: {
mode: "first-frame",
ratio: "16:9",
duration: 5,
generateAudio: false,
fps: 24,
},
};
}
return {
schemaVersion: "v1",
source: "provider-default",
providerType,
modeOptions: normalizeModeOptions([
"text-to-video",
"first-last-frame",
"reference-images",
"first-frame",
]),
ratioOptions: [...STANDARD_RATIOS],
durationOptions: [...STANDARD_DURATIONS],
resolutionOptions: [...STANDARD_RESOLUTIONS],
ui: {
showResolution: true,
showFps: true,
showCameraMove: true,
showGenerateAudio: false,
showReturnLastFrame: true,
showSeed: true,
},
defaults: {
mode: "text-to-video",
ratio: "16:9",
duration: 5,
resolution: "720p",
generateAudio: false,
fps: 24,
},
};
}
function pickPreferredRatio(ratioOptions: string[]) {
if (ratioOptions.includes("16:9")) return "16:9";
return ratioOptions[0] || "16:9";
}
function pickPreferredDuration(durationOptions: number[]) {
if (durationOptions.includes(5)) return 5;
if (durationOptions.includes(10)) return 10;
return durationOptions[0] || 5;
}
function pickPreferredMode(modeOptions: VideoToolModeOption[], fallback: VideoToolMode) {
const modeSet = new Set(modeOptions.map((item) => item.mode));
if (modeSet.has(fallback)) return fallback;
if (modeSet.has("first-frame")) return "first-frame";
if (modeSet.has("text-to-video")) return "text-to-video";
return modeOptions[0]?.mode || fallback;
}
export function buildVideoToolCapability(params: {
providerType?: string | null;
modelId?: string | null;
rawCapabilities?: unknown;
}): VideoToolCapabilityContract | null {
const providerType = String(params.providerType || "").trim();
if (!providerType) return null;
const base = createProviderDefaultCapability(providerType);
if (providerType === "fal-video" || providerType === "qianwen-video" || providerType === "ruxa-video") {
return {
...base,
modelId: params.modelId || undefined,
};
}
const parsed = normalizeVideoCapabilities(params.rawCapabilities);
const hasModelCapability =
Boolean(parsed.supportedModes?.length) ||
Boolean(parsed.supportedRatios?.length) ||
Boolean(parsed.supportedResolutions?.length) ||
Boolean(parsed.durationRange);
if (!hasModelCapability) {
return {
...base,
modelId: params.modelId || undefined,
};
}
const supportedModes =
parsed.supportedModes && parsed.supportedModes.length > 0
? parsed.supportedModes
: base.modeOptions.map((item) => item.mode);
const modeOptions = normalizeModeOptions(supportedModes, parsed.maxReferenceImages || 4);
const ratioOptions =
parsed.supportedRatios && parsed.supportedRatios.length > 0
? Array.from(new Set(parsed.supportedRatios))
: base.ratioOptions;
const resolutionOptions =
parsed.supportedResolutions && parsed.supportedResolutions.length > 0
? parsed.supportedResolutions
: base.resolutionOptions;
const durationOptions = parsed.durationRange
? normalizeDurations(parsed.durationRange.min, parsed.durationRange.max)
: base.durationOptions;
return {
...base,
source: "model-capabilities",
modelId: params.modelId || undefined,
modeOptions,
ratioOptions,
resolutionOptions,
durationOptions,
ui: {
...base.ui,
showReturnLastFrame:
typeof parsed.supportReturnLastFrame === "boolean"
? parsed.supportReturnLastFrame
: base.ui.showReturnLastFrame,
showSeed:
typeof parsed.supportSeed === "boolean" ? parsed.supportSeed : base.ui.showSeed,
},
defaults: {
...base.defaults,
mode: pickPreferredMode(modeOptions, base.defaults.mode),
ratio: pickPreferredRatio(ratioOptions),
duration: pickPreferredDuration(durationOptions),
resolution: resolutionOptions.includes("720p")
? "720p"
: resolutionOptions[0] || undefined,
},
};
}