ai-chatbot/lib/video-canvas/video-model-catalog.ts

506 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export const VIDEO_CANVAS_MODEL_ICON_KEYS = [
"seedance",
"happy-horse",
"kling",
"wan",
"hailuo",
"vidu",
"pixverse",
"omnihuman",
"generic",
] as const;
export type VideoCanvasModelIconKey =
(typeof VIDEO_CANVAS_MODEL_ICON_KEYS)[number];
export type VideoCanvasModelCatalogEntry = {
badge: string;
description: string;
discountRate: number;
durationLabel: string;
enabled: boolean;
icon: VideoCanvasModelIconKey;
id: string;
label: string;
membershipOnly: boolean;
modelRef: string;
pointsPerSecondByResolution: Record<string, number>;
shortLabel: string;
};
type CatalogSeed = Omit<
VideoCanvasModelCatalogEntry,
| "badge"
| "discountRate"
| "enabled"
| "membershipOnly"
| "modelRef"
| "pointsPerSecondByResolution"
> &
Partial<
Pick<
VideoCanvasModelCatalogEntry,
| "badge"
| "discountRate"
| "enabled"
| "membershipOnly"
| "modelRef"
| "pointsPerSecondByResolution"
>
>;
function createCatalogEntry(seed: CatalogSeed): VideoCanvasModelCatalogEntry {
return {
badge: seed.badge || "",
description: seed.description,
discountRate: seed.discountRate ?? 1,
durationLabel: seed.durationLabel,
enabled: seed.enabled !== false,
icon: seed.icon,
id: seed.id,
label: seed.label,
membershipOnly: seed.membershipOnly === true,
modelRef: seed.modelRef || "auto",
pointsPerSecondByResolution: seed.pointsPerSecondByResolution || {},
shortLabel: seed.shortLabel,
};
}
export const defaultVideoCanvasModelCatalog: VideoCanvasModelCatalogEntry[] = [
createCatalogEntry({
id: "seedance-2-vip",
label: "Seedance 2.0 VIP",
shortLabel: "2.0",
durationLabel: "2min",
description: "最强视频模型,会员专属通道,15s音画同步",
badge: "720P限时9折",
membershipOnly: true,
icon: "seedance",
discountRate: 0.9,
pointsPerSecondByResolution: { "720P": 27 },
}),
createCatalogEntry({
id: "seedance-2-fast-vip",
label: "Seedance 2.0 Fast VIP",
shortLabel: "2.0 Fast",
durationLabel: "2min",
description: "最强视频模型快速版,会员专属通道,15s音画同步",
badge: "720P限时9折",
membershipOnly: true,
icon: "seedance",
discountRate: 0.9,
pointsPerSecondByResolution: { "720P": 22 },
}),
createCatalogEntry({
id: "seedance-2-mini",
label: "Seedance 2.0 Mini",
shortLabel: "2.0 Mini",
durationLabel: "2min",
description: "最强视频模型mini版,高性价比生成,15s音画同步",
membershipOnly: true,
icon: "seedance",
}),
createCatalogEntry({
id: "happy-horse-1-1",
label: "Happy Horse 1.1",
shortLabel: "Happy Horse 1.1",
durationLabel: "3min",
description: "阿里最新视频模型,一致性与视听质量更可控",
badge: "限时4折",
membershipOnly: true,
icon: "happy-horse",
discountRate: 0.4,
}),
createCatalogEntry({
id: "happy-horse-1-0",
label: "Happy Horse 1.0",
shortLabel: "Happy Horse 1.0",
durationLabel: "3min",
description: "阿里视频模型,支持多参生成",
membershipOnly: true,
icon: "happy-horse",
}),
createCatalogEntry({
id: "kling-o3",
label: "Kling O3",
shortLabel: "Kling O3",
durationLabel: "3min",
description: "视频编辑模型、参考一致性、音画同出、多镜头",
membershipOnly: true,
icon: "kling",
pointsPerSecondByResolution: { "720P": 11 },
}),
createCatalogEntry({
id: "kling-3",
label: "Kling 3.0",
shortLabel: "Kling 3.0",
durationLabel: "3min",
description: "视频生成模型,高质感、支持多镜头",
membershipOnly: true,
icon: "kling",
}),
createCatalogEntry({
id: "wan-2-7",
label: "Wan 2.7",
shortLabel: "Wan 2.7",
durationLabel: "3min",
description: "全能参考,支持修改视频画面、剧情、环境",
icon: "wan",
}),
createCatalogEntry({
id: "kling-o1",
label: "Kling O1",
shortLabel: "Kling O1",
durationLabel: "3min",
description: "可灵一代编辑模型、支持多模态输入",
icon: "kling",
}),
createCatalogEntry({
id: "kling-2-6",
label: "Kling 2.6",
shortLabel: "Kling 2.6",
durationLabel: "2min",
description: "视频生成模型、直出音画同步",
icon: "kling",
}),
createCatalogEntry({
id: "kling-3-motion-transfer",
label: "Kling3.0 动作迁移",
shortLabel: "Kling3.0 动作迁移",
durationLabel: "8min",
description: "动作控制模型,需输入1张图片、1条视频",
icon: "kling",
}),
createCatalogEntry({
id: "mj-video",
label: "MJ Video",
shortLabel: "MJ Video",
durationLabel: "2min",
description: "图生视频效果稳定,画面表现力强",
icon: "generic",
}),
createCatalogEntry({
id: "hailuo-02",
label: "Hailuo 02",
shortLabel: "Hailuo 02",
durationLabel: "2min",
description: "画质稳定,适合打造运动特效场景",
icon: "hailuo",
}),
createCatalogEntry({
id: "vidu-q2",
label: "Vidu Q2",
shortLabel: "Vidu Q2",
durationLabel: "3min",
description: "多图主体参考,精确控制效果佳",
icon: "vidu",
}),
createCatalogEntry({
id: "vidu-q2-pro",
label: "Vidu Q2 Pro",
shortLabel: "Vidu Q2 Pro",
durationLabel: "",
description: "Vidu Q2 Pro",
icon: "vidu",
}),
createCatalogEntry({
id: "vidu-q2-turbo",
label: "Vidu Q2 Turbo",
shortLabel: "Vidu Q2 Turbo",
durationLabel: "",
description: "Vidu Q2 Turbo",
icon: "vidu",
}),
createCatalogEntry({
id: "kling-2-5",
label: "Kling 2.5",
shortLabel: "Kling 2.5",
durationLabel: "2min",
description: "速度快、效果稳定、性价比高",
icon: "kling",
}),
createCatalogEntry({
id: "kling-2-1",
label: "Kling 2.1",
shortLabel: "Kling 2.1",
durationLabel: "3min",
description: "支持首尾帧,图生视频效果更出色",
icon: "kling",
}),
createCatalogEntry({
id: "pixverse-v5-5",
label: "Pixverse V5.5",
shortLabel: "Pixverse V5.5",
durationLabel: "3min",
description: "支持特效、玩法丰富",
icon: "pixverse",
}),
createCatalogEntry({
id: "pixverse-v5",
label: "Pixverse V5",
shortLabel: "Pixverse V5",
durationLabel: "3min",
description: "支持特效、玩法丰富",
icon: "pixverse",
}),
createCatalogEntry({
id: "kling-3-turbo",
label: "Kling 3.0 Turbo",
shortLabel: "Kling 3.0 Turbo",
durationLabel: "3min",
description: "视频生成模型,高质感、支持多镜头",
icon: "kling",
}),
createCatalogEntry({
id: "wan-2-6",
label: "Wan 2.6",
shortLabel: "Wan 2.6",
durationLabel: "3min",
description: "音画同步,支持多机位镜头,最长可生15秒视频",
icon: "wan",
}),
createCatalogEntry({
id: "hailuo-2-3-fast",
label: "Hailuo 2.3 Fast",
shortLabel: "Hailuo 2.3 Fast",
durationLabel: "1min",
description: "善于表达动作、表情、镜头,更快速",
icon: "hailuo",
}),
createCatalogEntry({
id: "hailuo-2-3",
label: "Hailuo 2.3",
shortLabel: "Hailuo 2.3",
durationLabel: "2min",
description: "善于表达动作、表情、镜头,更高质感",
icon: "hailuo",
}),
createCatalogEntry({
id: "seedance-1-5-pro",
label: "Seedance1.5 Pro",
shortLabel: "Seedance1.5 Pro",
durationLabel: "2min",
description: "音画同步,支持多机位镜头,最长可生12秒视频",
icon: "seedance",
}),
createCatalogEntry({
id: "seedance-1-pro",
label: "Seedance 1.0 Pro",
shortLabel: "1.0 Pro",
durationLabel: "2min",
description: "高精度提示词理解,40秒生成1080P视频",
membershipOnly: true,
icon: "seedance",
}),
createCatalogEntry({
id: "seedance-1-lite",
label: "Seedance 1.0 Lite",
shortLabel: "1.0 Lite",
durationLabel: "1min",
description: "轻量快速,一键进行日常视频生成",
membershipOnly: true,
icon: "seedance",
}),
createCatalogEntry({
id: "vidu-q3-pro",
label: "Vidu Q3 Pro",
shortLabel: "Vidu Q3 Pro",
durationLabel: "2min",
description: "支持主体参考,精确控制效果佳",
membershipOnly: true,
icon: "vidu",
}),
createCatalogEntry({
id: "omnihuman-1-5",
label: "OmniHuman 1.5",
shortLabel: "OmniHuman 1.5",
durationLabel: "3min",
description: "多模态数字人视频生成",
membershipOnly: true,
icon: "omnihuman",
}),
createCatalogEntry({
id: "wan-2-2",
label: "Wan 2.2",
shortLabel: "Wan 2.2",
durationLabel: "3min",
description: "支持特效、玩法千变万化",
icon: "wan",
}),
createCatalogEntry({
id: "wan-2-5",
label: "Wan 2.5",
shortLabel: "Wan 2.5",
durationLabel: "3min",
description: "支持特效、直出音画同步",
icon: "wan",
}),
];
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeText(value: unknown, fallback: string, maxLength: number) {
return String(value ?? fallback).trim().slice(0, maxLength) || fallback;
}
function normalizeDiscountRate(value: unknown, fallback: number) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.min(1, Math.max(0.01, Math.round(numeric * 100) / 100));
}
function normalizePricingMap(value: unknown) {
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value)
.map(([resolution, points]) => [resolution.trim().slice(0, 24), Number(points)] as const)
.filter(
([resolution, points]) =>
Boolean(resolution) && Number.isFinite(points) && points >= 0,
)
.slice(0, 12)
.map(([resolution, points]) => [
resolution,
Math.min(100_000, Math.round(points * 100) / 100),
]),
);
}
export function normalizeVideoCanvasModelCatalog(
value: unknown,
): VideoCanvasModelCatalogEntry[] {
if (!Array.isArray(value) || value.length === 0) {
return defaultVideoCanvasModelCatalog.map((entry) => ({
...entry,
pointsPerSecondByResolution: { ...entry.pointsPerSecondByResolution },
}));
}
const usedIds = new Set<string>();
const normalized = value.flatMap((item, index) => {
if (!isRecord(item)) return [];
const fallback =
defaultVideoCanvasModelCatalog.find(
(entry) => entry.id === item.id || entry.label === item.label,
) || defaultVideoCanvasModelCatalog[index];
const label = normalizeText(item.label, fallback?.label || "", 120);
if (!label) return [];
const rawId = normalizeText(item.id, fallback?.id || `video-model-${index + 1}`, 120)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "") || `video-model-${index + 1}`;
let id = rawId;
let suffix = 2;
while (usedIds.has(id)) {
id = `${rawId}-${suffix}`;
suffix += 1;
}
usedIds.add(id);
const icon = VIDEO_CANVAS_MODEL_ICON_KEYS.includes(
item.icon as VideoCanvasModelIconKey,
)
? (item.icon as VideoCanvasModelIconKey)
: fallback?.icon || "generic";
return [
{
badge: normalizeText(item.badge, fallback?.badge || "", 60),
description: normalizeText(
item.description,
fallback?.description || label,
240,
),
discountRate: normalizeDiscountRate(
item.discountRate,
fallback?.discountRate ?? 1,
),
durationLabel: normalizeText(
item.durationLabel,
fallback?.durationLabel || "",
24,
),
enabled: item.enabled !== false,
icon,
id,
label,
membershipOnly:
typeof item.membershipOnly === "boolean"
? item.membershipOnly
: fallback?.membershipOnly === true,
modelRef: normalizeText(item.modelRef, fallback?.modelRef || "auto", 160),
pointsPerSecondByResolution: normalizePricingMap(
item.pointsPerSecondByResolution ?? fallback?.pointsPerSecondByResolution,
),
shortLabel: normalizeText(item.shortLabel, fallback?.shortLabel || label, 80),
},
];
});
return normalized.length > 0
? normalized.slice(0, 80)
: normalizeVideoCanvasModelCatalog(undefined);
}
export function getVideoCanvasModelCatalogEntry(
catalog: VideoCanvasModelCatalogEntry[],
selection: { label?: string; modelRef?: string },
) {
const enabledCatalog = catalog.filter((entry) => entry.enabled);
const label = String(selection.label || "").trim();
if (label) {
const labelMatch = enabledCatalog.find((entry) => entry.label === label);
if (labelMatch) return labelMatch;
}
const modelRef = String(selection.modelRef || "").trim();
if (modelRef && modelRef !== "auto") {
const modelMatch = enabledCatalog.find((entry) => entry.modelRef === modelRef);
if (modelMatch) return modelMatch;
}
return enabledCatalog[0] || catalog[0];
}
export function resolveVideoCanvasCatalogModelRef(
entry: VideoCanvasModelCatalogEntry,
fallbackModelRef: string,
) {
const configured = String(entry.modelRef || "").trim();
if (configured && configured !== "auto") return configured;
const fallback = String(fallbackModelRef || "").trim();
return fallback || "auto";
}
export function estimateVideoCanvasModelCatalogPoints(
entry: VideoCanvasModelCatalogEntry | undefined,
settings: {
count: number;
durationSeconds: number;
fallbackPointsPerSecond: number;
resolution: string;
},
) {
const configuredRate = Number(
entry?.pointsPerSecondByResolution[settings.resolution],
);
const fallbackRate = Number(settings.fallbackPointsPerSecond);
const pointsPerSecond = Number.isFinite(configuredRate)
? configuredRate
: Number.isFinite(fallbackRate)
? fallbackRate
: 0;
const originalCost = Math.max(
0,
Math.round(
Math.max(0, pointsPerSecond) *
Math.max(1, Math.round(settings.durationSeconds)) *
Math.max(1, Math.round(settings.count)),
),
);
const discountRate = entry?.discountRate ?? 1;
const cost = Math.max(0, Math.round(originalCost * discountRate));
return {
cost,
originalCost: cost === originalCost ? undefined : originalCost,
};
}