ai-chatbot/lib/ai/video-tool-capability.ts
Your Name dbfc8e97d2
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: align runway video params with model capabilities
2026-03-21 01:15:24 +08:00

592 lines
19 KiB
TypeScript

import type { VideoModelCapabilities } from "@/types/ai";
import { getRunwayVideoModelDefinition } from "@/lib/ai/runway-video-models";
import type {
VideoToolCapabilityContract,
VideoToolDefaults,
VideoToolMode,
VideoToolModeConstraint,
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];
type ParsedVideoCapabilities = Partial<VideoModelCapabilities> & {
constraints?: Partial<Record<VideoToolMode, VideoToolModeConstraint>>;
};
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 normalizeModeConstraints(
rawConstraints: unknown
): Partial<Record<VideoToolMode, VideoToolModeConstraint>> | undefined {
const raw = asObject(rawConstraints);
if (!raw) return undefined;
const modeConstraints: Partial<Record<VideoToolMode, VideoToolModeConstraint>> = {};
for (const mode of MODE_ORDER) {
const entry = asObject(raw[mode]);
if (!entry) continue;
const ratioOptions = Array.isArray(entry.ratioOptions)
? entry.ratioOptions.map((item) => String(item || "").trim()).filter(Boolean)
: undefined;
const durationOptions = Array.isArray(entry.durationOptions)
? entry.durationOptions
.map((item) => Number(item))
.filter((item) => Number.isFinite(item))
.map((item) => Math.round(item))
: undefined;
const resolutionOptions = Array.isArray(entry.resolutionOptions)
? entry.resolutionOptions.filter(
(item): item is VideoToolResolution =>
item === "480p" || item === "720p" || item === "1080p"
)
: undefined;
const defaults = asObject(entry.defaults);
modeConstraints[mode] = {
ratioOptions: ratioOptions?.length ? Array.from(new Set(ratioOptions)) : undefined,
durationOptions: durationOptions?.length ? Array.from(new Set(durationOptions)) : undefined,
resolutionOptions: resolutionOptions?.length ? Array.from(new Set(resolutionOptions)) : undefined,
defaults: defaults
? {
mode,
ratio: typeof defaults.ratio === "string" ? defaults.ratio : undefined,
duration: Number.isFinite(Number(defaults.duration))
? Math.round(Number(defaults.duration))
: undefined,
resolution:
defaults.resolution === "480p" ||
defaults.resolution === "720p" ||
defaults.resolution === "1080p"
? defaults.resolution
: undefined,
generateAudio:
typeof defaults.generateAudio === "boolean" ? defaults.generateAudio : undefined,
fps: Number.isFinite(Number(defaults.fps)) ? Math.round(Number(defaults.fps)) : undefined,
}
: undefined,
};
}
return Object.keys(modeConstraints).length > 0 ? modeConstraints : undefined;
}
function normalizeVideoCapabilities(rawCapabilities: unknown): ParsedVideoCapabilities {
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,
constraints: normalizeModeConstraints(raw.modeConstraints || raw.constraints),
};
}
function createRunwayDefaultCapability(modelId?: string | null): VideoToolCapabilityContract {
const definition = getRunwayVideoModelDefinition(modelId);
if (!definition) {
return {
schemaVersion: "v1",
source: "provider-default",
providerType: "runway",
modelId: modelId || undefined,
modeOptions: normalizeModeOptions(["text-to-video", "first-frame"]),
ratioOptions: [...STANDARD_RATIOS],
durationOptions: [5, 10],
resolutionOptions: [],
ui: {
showResolution: false,
showFps: false,
showCameraMove: false,
showGenerateAudio: false,
showReturnLastFrame: false,
showSeed: true,
},
defaults: {
mode: "text-to-video",
ratio: "16:9",
duration: 5,
generateAudio: false,
fps: 24,
},
};
}
const modeEntries = MODE_ORDER.map((mode) => {
const modeDefinition = definition.modes[mode];
if (!modeDefinition) return null;
return {
mode,
label: MODE_LABELS[mode],
imageMin: modeDefinition.imageMin,
imageMax: modeDefinition.imageMax,
} satisfies VideoToolModeOption;
}).filter((item): item is VideoToolModeOption => Boolean(item));
const modeConstraints = MODE_ORDER.reduce<Partial<Record<VideoToolMode, VideoToolModeConstraint>>>(
(acc, mode) => {
const modeDefinition = definition.modes[mode];
if (!modeDefinition) return acc;
acc[mode] = {
ratioOptions: modeDefinition.ratioOptions,
durationOptions: modeDefinition.durationOptions,
resolutionOptions: modeDefinition.resolutionOptions || [],
defaults: modeDefinition.defaults,
};
return acc;
},
{}
);
const ratioOptions = Array.from(
new Set(
Object.values(definition.modes)
.flatMap((modeDefinition) => modeDefinition?.ratioOptions || [])
.filter(Boolean)
)
);
const durationOptions = Array.from(
new Set(
Object.values(definition.modes)
.flatMap((modeDefinition) => modeDefinition?.durationOptions || [])
.filter((item): item is number => Number.isFinite(item))
)
).sort((a, b) => a - b);
return {
schemaVersion: "v1",
source: "provider-default",
providerType: "runway",
modelId: modelId || undefined,
modeOptions: modeEntries,
ratioOptions,
durationOptions,
resolutionOptions: [],
modeConstraints,
ui: { ...definition.ui },
defaults: { ...definition.defaults },
};
}
function createProviderDefaultCapability(
providerType: string,
modelId?: string | null
): 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,
},
};
}
if (providerType === "runway") {
return createRunwayDefaultCapability(modelId);
}
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;
}
function pickPreferredResolution(resolutionOptions: VideoToolResolution[]) {
return resolutionOptions.includes("720p") ? "720p" : resolutionOptions[0] || undefined;
}
function getModeConstraintOptions(
modeConstraints: Partial<Record<VideoToolMode, VideoToolModeConstraint>> | undefined,
mode: VideoToolMode | undefined,
defaults: VideoToolDefaults
) {
const constraint = mode ? modeConstraints?.[mode] : undefined;
const ratioOptions = constraint?.ratioOptions?.length ? constraint.ratioOptions : [];
const durationOptions = constraint?.durationOptions?.length ? constraint.durationOptions : [];
const resolutionOptions = constraint?.resolutionOptions?.length
? constraint.resolutionOptions
: [];
return {
ratioOptions,
durationOptions,
resolutionOptions,
defaults: {
...defaults,
...(constraint?.defaults || {}),
} as VideoToolDefaults,
};
}
export function getVideoToolOptionsForMode(
capability: VideoToolCapabilityContract | null | undefined,
mode?: VideoToolMode | null
) {
if (!capability) {
return {
ratioOptions: [] as string[],
durationOptions: [] as number[],
resolutionOptions: [] as VideoToolResolution[],
defaults: null as VideoToolDefaults | null,
};
}
const fallbackMode =
(mode && capability.modeOptions.some((item) => item.mode === mode) ? mode : undefined) ||
capability.defaults.mode ||
capability.modeOptions[0]?.mode;
const scoped = getModeConstraintOptions(capability.modeConstraints, fallbackMode, capability.defaults);
return {
ratioOptions: scoped.ratioOptions.length ? scoped.ratioOptions : capability.ratioOptions,
durationOptions: scoped.durationOptions.length
? scoped.durationOptions
: capability.durationOptions,
resolutionOptions: scoped.resolutionOptions.length
? scoped.resolutionOptions
: capability.resolutionOptions,
defaults: scoped.defaults,
};
}
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, params.modelId);
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) ||
Boolean(
parsed.constraints &&
Object.values(parsed.constraints).some((constraint) => Boolean(constraint))
);
if (!hasModelCapability) {
return {
...base,
modelId: params.modelId || undefined,
};
}
const supportedModes =
parsed.supportedModes && parsed.supportedModes.length > 0
? parsed.supportedModes
: parsed.constraints && Object.keys(parsed.constraints).length > 0
? MODE_ORDER.filter((mode) => Boolean(parsed.constraints?.[mode]))
: 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;
const baseModeConstraints = base.modeConstraints || {};
const nextModeConstraints =
parsed.constraints && Object.keys(parsed.constraints).length > 0
? MODE_ORDER.reduce<Partial<Record<VideoToolMode, VideoToolModeConstraint>>>((acc, mode) => {
const baseConstraint = baseModeConstraints[mode];
const parsedConstraint = parsed.constraints?.[mode];
if (!baseConstraint && !parsedConstraint) return acc;
acc[mode] = {
ratioOptions:
parsedConstraint?.ratioOptions?.length
? parsedConstraint.ratioOptions
: baseConstraint?.ratioOptions,
durationOptions:
parsedConstraint?.durationOptions?.length
? parsedConstraint.durationOptions
: parsedConstraint
? durationOptions
: baseConstraint?.durationOptions,
resolutionOptions:
parsedConstraint?.resolutionOptions?.length
? parsedConstraint.resolutionOptions
: baseConstraint?.resolutionOptions,
defaults: {
...(baseConstraint?.defaults || {}),
...(parsedConstraint?.defaults || {}),
},
};
return acc;
}, {})
: base.modeConstraints;
const defaultMode = pickPreferredMode(modeOptions, base.defaults.mode);
const modeScopedDefaults = getModeConstraintOptions(
nextModeConstraints,
defaultMode,
base.defaults
);
const effectiveRatioOptions = modeScopedDefaults.ratioOptions.length
? modeScopedDefaults.ratioOptions
: ratioOptions;
const effectiveDurationOptions = modeScopedDefaults.durationOptions.length
? modeScopedDefaults.durationOptions
: durationOptions;
const effectiveResolutionOptions = modeScopedDefaults.resolutionOptions.length
? modeScopedDefaults.resolutionOptions
: resolutionOptions;
return {
...base,
source: "model-capabilities",
modelId: params.modelId || undefined,
modeOptions,
ratioOptions,
resolutionOptions,
durationOptions,
modeConstraints: nextModeConstraints,
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,
...modeScopedDefaults.defaults,
mode: defaultMode,
ratio: pickPreferredRatio(effectiveRatioOptions),
duration: pickPreferredDuration(effectiveDurationOptions),
resolution: pickPreferredResolution(effectiveResolutionOptions),
},
};
}