import type { VideoModelCapabilities } from "@/types/ai"; import type { VideoToolCapabilityContract, VideoToolMode, VideoToolModeOption, VideoToolResolution, } from "@/types/video-tool-capability"; const MODE_LABELS: Record = { "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) : 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 { 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, }, }; }