ai-chatbot/lib/ai/tools/generate-video.ts

772 lines
24 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.

/**
* 视频生成工具
* 支持火山引擎视频生成的多种模式
*/
import { tool } from "ai";
import { z } from "zod";
import { doGenerateStream } from "@/lib/ai/providers/volcano-video";
import { executeAtlasCloudVideoGeneration } from "@/lib/ai/providers/atlascloud";
import {
getEnabledAiModelsWithProvider,
getSubModelsByParentModelId
} from "@/lib/db/queries";
import { getConfiguredVideoDurationOptions } from "@/lib/ai/video-model-config";
import type {
VideoGenerationMode,
VideoModelCapabilities,
VideoGenerationOptions,
} from "@/types/ai";
export interface ExecuteDirectVideoGenerationParams {
prompt: string;
mode: VideoGenerationMode;
images?: string[];
resolution?: "480p" | "720p" | "1080p";
ratio?: "16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9" | "adaptive";
duration?: number;
watermark?: boolean;
cameraFixed?: boolean;
seed?: number;
returnLastFrame?: boolean;
preferredModelRef?: string;
onProgress?: (chunk: string) => void | Promise<void>;
}
export interface ExecuteDirectVideoGenerationResult {
success: boolean;
videoUrl?: string;
metadata?: Record<string, any> | null;
modelDisplayName: string;
modelId: string;
modelRef: string;
}
function inferSupportedModesFromModelId(modelId: string): VideoGenerationMode[] {
const normalized = modelId.toLowerCase();
if (normalized.includes("lite-t2v")) return ["text-to-video"];
if (normalized.includes("lite-i2v")) return ["first-frame", "first-last-frame", "reference-images"];
if (normalized.includes("pro-fast")) return ["text-to-video", "first-frame"];
if (normalized.includes("-pro-") || normalized.endsWith("-pro")) {
return ["text-to-video", "first-frame", "first-last-frame"];
}
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
}
function getSupportedModes(caps: Partial<VideoModelCapabilities> | undefined, modelId: string): VideoGenerationMode[] {
const modes = (caps?.supportedModes || []) as VideoGenerationMode[];
if (Array.isArray(modes) && modes.length > 0) {
return modes;
}
return inferSupportedModesFromModelId(modelId);
}
function withConfiguredVideoCapabilities(
capabilities: Partial<VideoModelCapabilities> | undefined,
imageGenConfig?: { videoDurationOptions?: number[] | null } | null
): VideoModelCapabilities {
const baseCapabilities = {
supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"],
supportedResolutions: ["480p", "720p", "1080p"],
supportedRatios: ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"],
durationRange: { min: 2, max: 12 },
maxReferenceImages: 4,
...(capabilities || {}),
} as VideoModelCapabilities;
const durationOptions = getConfiguredVideoDurationOptions(imageGenConfig);
if (durationOptions.length === 0) {
return baseCapabilities;
}
return {
...baseCapabilities,
durationOptions,
durationRange: {
min: durationOptions[0],
max: durationOptions[durationOptions.length - 1],
},
};
}
function isAgnesVideoModel(item: any) {
const capabilities = item?.model?.capabilities;
const provider = capabilities && typeof capabilities === "object"
? String((capabilities as Record<string, unknown>).provider || "")
: "";
const kind = capabilities && typeof capabilities === "object"
? String((capabilities as Record<string, unknown>).kind || "")
: "";
const modelId = String(item?.model?.modelId || "").toLowerCase();
return (
item?.provider?.type === "custom" &&
(provider === "agnes-ai" || (kind === "video" && modelId.startsWith("agnes-video")))
);
}
function isVolcanoVideoModel(item: any) {
return item?.provider?.type === "volcano-video" || item?.provider?.type === "doubao-video";
}
function isAtlasCloudVideoModel(item: any) {
const capabilities = item?.model?.capabilities;
return (
item?.provider?.type === "atlascloud" &&
capabilities &&
typeof capabilities === "object" &&
String((capabilities as Record<string, unknown>).mediaType || "") === "video"
);
}
function isDirectExecutableVideoModel(item: any) {
return (
isVolcanoVideoModel(item) ||
isAgnesVideoModel(item) ||
isAtlasCloudVideoModel(item)
);
}
/**
* 🆕 智能选择最佳子模型
* 根据用户选择的模式和参数,从子模型列表中选择最合适的模型
*/
async function selectBestSubModel(
parentModelId: string,
mode: VideoGenerationMode,
params: Partial<VideoGenerationOptions>
): Promise<{ model: any; capabilities: VideoModelCapabilities }> {
// 获取所有子模型
const subModels = await getSubModelsByParentModelId(parentModelId);
if (subModels.length === 0) {
const allModels = await getEnabledAiModelsWithProvider();
const directModel = allModels.find(
(item) =>
item.model.id === parentModelId &&
isDirectExecutableVideoModel(item),
);
if (!directModel) {
throw new Error("没有可用的视频生成子模型");
}
const directCapabilities = withConfiguredVideoCapabilities(
directModel.model.capabilities as VideoModelCapabilities | undefined,
(directModel.model as any).imageGenConfig
);
const supportedModes = getSupportedModes(directCapabilities, directModel.model.modelId);
if (!supportedModes.includes(mode)) {
throw new Error(`模型 ${directModel.model.displayName} 不支持 ${mode} 模式`);
}
return {
model: directModel,
capabilities: {
...directCapabilities,
supportedModes,
} as VideoModelCapabilities,
};
}
console.log(`[Smart Model Selection] 找到 ${subModels.length} 个子模型`);
// 筛选支持指定模式的子模型
const compatibleModels = subModels.filter((m) => {
const caps = withConfiguredVideoCapabilities(
m.model.capabilities as VideoModelCapabilities,
(m.model as any).imageGenConfig
);
const supportsMode = getSupportedModes(caps, m.model.modelId).includes(mode);
// 检查分辨率支持(如果用户指定了分辨率)
const supportedResolutions = caps.supportedResolutions || ["480p", "720p", "1080p"];
const supportsResolution = !params.resolution || supportedResolutions.includes(params.resolution);
return supportsMode && supportsResolution;
});
if (compatibleModels.length === 0) {
throw new Error(`没有模型支持 ${mode} 模式和指定的参数`);
}
console.log(`[Smart Model Selection] ${compatibleModels.length} 个模型支持 ${mode} 模式`);
// 🎯 智能优先级排序
const priority: Record<string, number> = {
"doubao-seedance-1-0-pro-250528": 10, // 最高优先级(全功能)
"doubao-seedance-1-0-pro-fast-251015": 8, // 快速模式
"doubao-seedance-1-0-lite-i2v-250428": 6, // 图生视频轻量版
"doubao-seedance-1-0-lite-t2v-250428": 5, // 文生视频轻量版
};
// 选择最佳模型
const bestModel = compatibleModels.sort((a, b) => {
const priorityA = priority[a.model.modelId] || 0;
const priorityB = priority[b.model.modelId] || 0;
return priorityB - priorityA;
})[0];
console.log(`[Smart Model Selection] ✅ 选择模型: ${bestModel.model.displayName} (${bestModel.model.modelId})`);
return {
model: bestModel,
capabilities: withConfiguredVideoCapabilities(
bestModel.model.capabilities as VideoModelCapabilities,
(bestModel.model as any).imageGenConfig
),
};
}
/**
* 获取支持指定模式的视频模型(兼容旧逻辑)
*/
async function getCompatibleModel(
mode: VideoGenerationMode,
parentModelId?: string,
params?: Partial<VideoGenerationOptions>
): Promise<{ model: any; capabilities: VideoModelCapabilities }> {
// 🆕 如果提供了parentModelId,使用智能子模型选择
if (parentModelId) {
return selectBestSubModel(parentModelId, mode, params || {});
}
// 兼容旧逻辑:直接从所有模型中选择(用于向后兼容)
const models = await getEnabledAiModelsWithProvider();
const volcanoVideoModels = models.filter(
(m) =>
isDirectExecutableVideoModel(m) &&
m.model.isEnabled &&
m.model.capabilities
);
if (volcanoVideoModels.length === 0) {
throw new Error("没有启用的可执行视频模型");
}
const compatibleModels = volcanoVideoModels.filter((m) => {
const caps = withConfiguredVideoCapabilities(
m.model.capabilities as VideoModelCapabilities,
(m.model as any).imageGenConfig
);
return getSupportedModes(caps, m.model.modelId).includes(mode);
});
if (compatibleModels.length === 0) {
throw new Error(`没有模型支持 ${mode} 模式`);
}
const priority: Record<string, number> = {
"doubao-seedance-1-0-pro-250528": 10,
"doubao-seedance-1-0-pro-fast-251015": 8,
"doubao-seedance-1-0-lite-i2v-250428": 6,
"doubao-seedance-1-0-lite-t2v-250428": 5,
};
const bestModel = compatibleModels.sort((a, b) => {
const priorityA = priority[a.model.modelId] || 0;
const priorityB = priority[b.model.modelId] || 0;
return priorityB - priorityA;
})[0];
return {
model: bestModel,
capabilities: withConfiguredVideoCapabilities(
bestModel.model.capabilities as VideoModelCapabilities,
(bestModel.model as any).imageGenConfig
),
};
}
/**
* 验证参数是否符合模型能力
*/
function validateParamsAgainstCapabilities(
params: Partial<VideoGenerationOptions>,
capabilities: VideoModelCapabilities
): void {
// 检查分辨率
if (
params.resolution &&
capabilities.supportedResolutions.length > 0 &&
!capabilities.supportedResolutions.includes(params.resolution)
) {
throw new Error(
`模型不支持 ${params.resolution} 分辨率,支持的分辨率:${capabilities.supportedResolutions.join(", ")}`
);
}
// 检查宽高比
if (
params.ratio &&
capabilities.supportedRatios.length > 0 &&
!capabilities.supportedRatios.includes(params.ratio)
) {
throw new Error(
`模型不支持 ${params.ratio} 宽高比,支持的宽高比:${capabilities.supportedRatios.join(", ")}`
);
}
// 检查时长范围
if (params.duration) {
const allowedDurations = Array.isArray(capabilities.durationOptions)
? capabilities.durationOptions
: [];
if (allowedDurations.length > 0) {
if (!allowedDurations.includes(params.duration)) {
throw new Error(`视频时长仅支持 ${allowedDurations.join(" / ")} 秒`);
}
} else {
const { min, max } = capabilities.durationRange;
if (params.duration < min || params.duration > max) {
throw new Error(`视频时长必须在 ${min}-${max} 秒之间`);
}
}
}
// 检查参考图数量
if (
params.mode === "reference-images" &&
params.referenceImages &&
capabilities.maxReferenceImages
) {
if (params.referenceImages.length > capabilities.maxReferenceImages) {
throw new Error(
`参考图数量不能超过 ${capabilities.maxReferenceImages} 张`
);
}
}
}
/**
* 构建视频生成选项
*/
function buildVideoOptions(
mode: VideoGenerationMode,
images: string[] | undefined,
prompt: string,
params: any,
modelId: string
): VideoGenerationOptions {
const options: VideoGenerationOptions = {
prompt,
model: modelId,
mode,
resolution: params.resolution || "720p",
ratio: params.ratio || "16:9",
duration: params.duration || 5,
watermark: params.watermark ?? true,
cameraFixed: params.cameraFixed ?? false,
fps: 24,
};
// 根据模式处理图片输入
if (mode === "first-frame" && images && images.length > 0) {
options.firstFrameImage = images[0];
} else if (mode === "first-last-frame" && images && images.length >= 2) {
options.firstFrameImage = images[0];
options.lastFrameImage = images[1];
} else if (mode === "reference-images" && images && images.length > 0) {
options.referenceImages = images.slice(0, 4);
}
return options;
}
async function resolvePreferredVideoParentModelId(preferredModelRef?: string) {
const normalizedRef = String(preferredModelRef || "").trim();
if (!normalizedRef) return undefined;
const models = await getEnabledAiModelsWithProvider();
const matched = models.find(
(item) =>
isDirectExecutableVideoModel(item) &&
(item.model.id === normalizedRef || item.model.modelId === normalizedRef),
);
if (!matched) return undefined;
if (matched.model.isSubModel && matched.model.parentModelId) {
return matched.model.parentModelId;
}
return matched.model.id;
}
function resolveAgnesVideoSize(
resolution: VideoGenerationOptions["resolution"],
ratio: VideoGenerationOptions["ratio"],
) {
const longSideByResolution: Record<string, number> = {
"480p": 854,
"720p": 1280,
"1080p": 1920,
};
const longSide = longSideByResolution[resolution || "720p"] || 1280;
const ratios: Record<string, [number, number]> = {
"16:9": [16, 9],
"4:3": [4, 3],
"1:1": [1, 1],
"3:4": [3, 4],
"9:16": [9, 16],
"21:9": [21, 9],
adaptive: [16, 9],
};
const [rw, rh] = ratios[ratio || "16:9"] || ratios["16:9"];
const landscape = rw >= rh;
const width = landscape ? longSide : Math.round((longSide * rw) / rh);
const height = landscape ? Math.round((longSide * rh) / rw) : longSide;
return {
width: Math.max(256, Math.round(width / 8) * 8),
height: Math.max(256, Math.round(height / 8) * 8),
};
}
function resolveAgnesFrameCount(duration?: number) {
const seconds = Math.max(1, Math.min(Number(duration) || 5, 18));
const rawFrames = Math.min(441, Math.max(9, Math.round(seconds * 24)));
return Math.min(441, Math.max(9, Math.floor((rawFrames - 1) / 8) * 8 + 1));
}
function buildAgnesResultUrl(baseUrl: string, videoId: string, modelId: string) {
const normalizedBase = String(baseUrl || "https://apihub.agnes-ai.com/v1").replace(/\/+$/, "");
const root = normalizedBase.replace(/\/v1$/i, "");
const url = new URL(`${root}/agnesapi`);
url.searchParams.set("video_id", videoId);
url.searchParams.set("model_name", modelId);
return url.toString();
}
async function executeAgnesVideoGeneration(params: {
apiKey: string;
baseUrl: string;
options: VideoGenerationOptions;
}) {
const baseUrl = String(params.baseUrl || "https://apihub.agnes-ai.com/v1").replace(/\/+$/, "");
const createUrl = `${baseUrl}/videos`;
const { width, height } = resolveAgnesVideoSize(params.options.resolution, params.options.ratio);
const body: Record<string, unknown> = {
model: params.options.model,
prompt: params.options.prompt,
width,
height,
num_frames: resolveAgnesFrameCount(params.options.duration),
frame_rate: 24,
};
if (params.options.seed !== undefined) body.seed = params.options.seed;
if (params.options.firstFrameImage && params.options.lastFrameImage) {
body.extra_body = {
image: [params.options.firstFrameImage, params.options.lastFrameImage],
mode: "keyframes",
};
} else if (params.options.referenceImages?.length) {
body.extra_body = {
image: params.options.referenceImages.slice(0, 4),
mode: "keyframes",
};
} else if (params.options.firstFrameImage) {
body.image = params.options.firstFrameImage;
body.mode = "ti2vid";
}
const createResponse = await fetch(createUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const createText = await createResponse.text();
let createData: Record<string, any> = {};
try {
createData = createText ? JSON.parse(createText) : {};
} catch {
createData = { raw: createText };
}
if (!createResponse.ok) {
throw new Error(
`Agnes 视频任务创建失败: ${createData?.error?.message || createData?.message || createResponse.statusText}`,
);
}
const videoId = String(createData.video_id || createData.videoId || createData.id || createData.task_id || "").trim();
const taskId = String(createData.task_id || createData.id || "").trim();
if (!videoId && !taskId) {
throw new Error("Agnes 视频任务创建成功但没有返回 video_id/task_id");
}
const startedAt = Date.now();
const timeoutMs = 285_000;
let lastData = createData;
while (Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const resultUrl = videoId
? buildAgnesResultUrl(baseUrl, videoId, params.options.model)
: `${baseUrl}/videos/${encodeURIComponent(taskId)}`;
const resultResponse = await fetch(resultUrl, {
headers: { Authorization: `Bearer ${params.apiKey}` },
});
const resultText = await resultResponse.text();
let resultData: Record<string, any> = {};
try {
resultData = resultText ? JSON.parse(resultText) : {};
} catch {
resultData = { raw: resultText };
}
if (!resultResponse.ok) {
throw new Error(
`Agnes 视频结果查询失败: ${resultData?.error?.message || resultData?.message || resultResponse.statusText}`,
);
}
lastData = resultData;
const status = String(resultData.status || "").toLowerCase();
if (status === "completed" && resultData.url) {
return {
url: String(resultData.url),
metadata: {
...resultData,
createTask: createData,
},
};
}
if (status === "failed") {
throw new Error(`Agnes 视频生成失败: ${resultData?.error?.message || resultData?.error || "任务失败"}`);
}
}
throw new Error(
`Agnes 视频生成超时: ${String(lastData.status || "unknown")}`,
);
}
export async function executeDirectVideoGeneration(
params: ExecuteDirectVideoGenerationParams,
): Promise<ExecuteDirectVideoGenerationResult> {
const prompt = String(params.prompt || "").trim();
if (!prompt) {
throw new Error("缺少视频生成提示词");
}
const preferredParentModelId = await resolvePreferredVideoParentModelId(
params.preferredModelRef,
);
const allModels = await getEnabledAiModelsWithProvider();
const fallbackParentModel =
allModels.find(
(item) =>
isVolcanoVideoModel(item) &&
!item.model.isSubModel &&
item.model.modelId === "volcano-video-master",
) ||
allModels.find(
(item) =>
isDirectExecutableVideoModel(item) &&
!item.model.isSubModel,
);
const parentModelId =
preferredParentModelId ||
fallbackParentModel?.model.id ||
allModels.find(
(item) =>
isDirectExecutableVideoModel(item) &&
item.model.isEnabled &&
item.model.isSubModel &&
item.model.parentModelId,
)?.model.parentModelId ||
undefined;
const generationParams = {
mode: params.mode,
resolution: params.resolution || "720p",
ratio: params.ratio || "16:9",
duration: params.duration || 5,
watermark: params.watermark ?? true,
cameraFixed: params.cameraFixed ?? false,
seed: params.seed,
returnLastFrame: params.returnLastFrame ?? false,
referenceImages: params.images,
} satisfies Partial<VideoGenerationOptions>;
let selectedModelConfig: { model: any; capabilities: VideoModelCapabilities };
try {
selectedModelConfig = await getCompatibleModel(
params.mode,
parentModelId,
generationParams,
);
} catch (preferredModelError) {
if (parentModelId) {
if (
preferredModelError instanceof Error &&
/不支持|没有模型支持/.test(preferredModelError.message)
) {
throw preferredModelError;
}
console.warn(
"[Generate Video Tool] Preferred parent model selection failed, fallback to global compatible model:",
preferredModelError,
);
selectedModelConfig = await getCompatibleModel(
params.mode,
undefined,
generationParams,
);
} else {
throw preferredModelError;
}
}
const { model, capabilities } = selectedModelConfig;
validateParamsAgainstCapabilities(generationParams, capabilities);
const options = buildVideoOptions(
params.mode,
params.images,
prompt,
generationParams,
model.model.modelId,
);
let videoMetadata: Record<string, any> | null = null;
if (isAgnesVideoModel(model)) {
const agnesResult = await executeAgnesVideoGeneration({
apiKey: model.provider.apiKey,
baseUrl: model.provider.baseUrl,
options,
});
videoMetadata = {
...(agnesResult.metadata || {}),
temporaryVideoUrl: agnesResult.url,
};
} else if (isAtlasCloudVideoModel(model)) {
const atlasResult = await executeAtlasCloudVideoGeneration({
apiKey: model.provider.apiKey,
baseURL: model.provider.baseUrl,
modelId: model.model.modelId,
modelConfig: model.model.imageGenConfig || {},
options,
});
videoMetadata = {
...(atlasResult.metadata || {}),
temporaryVideoUrl: atlasResult.url,
};
} else {
for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) {
await params.onProgress?.(chunk);
const metadataMatch = chunk.match(/<!-- VIDEO_METADATA:(.*?) -->/);
if (metadataMatch) {
try {
videoMetadata = JSON.parse(metadataMatch[1]);
} catch (error) {
console.error("[Generate Video Tool] 解析元数据失败:", error);
}
}
}
}
return {
success: true,
videoUrl: videoMetadata?.temporaryVideoUrl || undefined,
metadata: videoMetadata,
modelDisplayName: String(model.model.displayName || model.model.modelId || "视频模型"),
modelId: String(model.model.modelId || model.model.id),
modelRef: String(model.model.id || model.model.modelId),
};
}
/**
* 视频生成工具
*/
export function generateVideo({ dataStream }: { dataStream: any }) {
return tool({
description:
"生成视频,支持文生视频、图生视频(首帧/首尾帧/参考图)等多种模式",
inputSchema: z.object({
prompt: z.string().describe("视频生成提示词,描述视频内容、镜头运动等"),
mode: z
.enum(["text-to-video", "first-frame", "first-last-frame", "reference-images"])
.describe("生成模式:文生视频/图生视频-首帧/首尾帧/参考图"),
images: z
.array(z.string())
.optional()
.describe("图片 URL 数组(根据模式不同,需要 0-4 张)"),
resolution: z
.enum(["480p", "720p", "1080p"])
.default("720p")
.describe("视频分辨率"),
ratio: z
.enum(["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"])
.default("16:9")
.describe("视频宽高比"),
duration: z
.number()
.min(1)
.max(600)
.default(5)
.describe("视频时长(秒)。优先使用当前视频模型配置的秒数档位,服务端会按模型能力动态校验。"),
watermark: z.boolean().default(true).describe("是否添加水印"),
cameraFixed: z.boolean().default(false).describe("是否固定摄像头"),
seed: z.number().optional().describe("随机种子(可选)"),
returnLastFrame: z
.boolean()
.default(false)
.describe("是否返回尾帧(用于连续生成)"),
}),
execute: async ({ prompt, mode, images, resolution, ratio, duration, watermark, cameraFixed, seed, returnLastFrame }) => {
try {
console.log("[Generate Video Tool] 🎬 开始生成视频");
console.log("[Generate Video Tool] 模式:", mode);
console.log("[Generate Video Tool] 提示词:", prompt);
dataStream.writeData({
type: "video-generation-start",
data: {
mode,
model: "智能选择中",
},
});
const result = await executeDirectVideoGeneration({
prompt,
mode,
images,
resolution,
ratio,
duration,
watermark,
cameraFixed,
seed,
returnLastFrame,
onProgress: async (chunk) => {
dataStream.writeMessageAnnotation({
type: "video-progress",
content: chunk,
});
},
});
console.log("[Generate Video Tool] ✅ 视频生成完成");
return {
success: true,
videoUrl: result.videoUrl,
metadata: result.metadata,
modelDisplayName: result.modelDisplayName,
modelId: result.modelId,
modelRef: result.modelRef,
};
} catch (error: any) {
console.error("[Generate Video Tool] ❌ 生成失败:", error);
dataStream.writeMessageAnnotation({
type: "video-error",
content: `视频生成失败:${error.message}`,
});
throw error;
}
},
});
}