video-flow-toon/src/utils/ai/generateVideo.ts

438 lines
16 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 axios from "axios";
import u from "@/utils";
import FormData from "form-data";
import sharp from "sharp";
type VideoAspectRatio = "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "21:9" | "adaptive";
interface BaseVideoConfig {
prompt: string;
savePath: string;
imageBase64?: string[]; // 单张参考图片 base64
}
interface DoubaoVideoConfig extends BaseVideoConfig {
duration: 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; // 支持 2~12 秒
aspectRatio: VideoAspectRatio;
audio?: boolean;
}
interface RunninghubVideoConfig extends BaseVideoConfig {
duration: 10 | 15; // 仅支持 10 或 15 秒
aspectRatio: "16:9" | "9:16" | "1:1"; // 仅支持这三种比例
}
interface OpenAIVideoConfig extends BaseVideoConfig {
duration: 10 | 15; // 仅支持 10 或 15 秒
aspectRatio: Exclude<VideoAspectRatio, "adaptive">; // 不支持 adaptive
}
type VideoConfig = DoubaoVideoConfig | RunninghubVideoConfig | OpenAIVideoConfig;
const urlToBase64 = async (url: string): Promise<string> => {
const res = await axios.get(url, { responseType: "arraybuffer" });
const base64 = Buffer.from(res.data).toString("base64");
const mimeType = res.headers["content-type"] || "image/png";
return `data:${mimeType};base64,${base64}`;
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pollTask = async (
queryFn: () => Promise<{ completed: boolean; imageUrl?: string; error?: string }>,
maxAttempts = 500,
interval = 2000,
): Promise<string> => {
for (let i = 0; i < maxAttempts; i++) {
await sleep(interval);
const { completed, imageUrl, error } = await queryFn();
if (error) throw new Error(error);
if (completed && imageUrl) return imageUrl;
}
throw new Error(`任务轮询超时,已尝试 ${maxAttempts}`);
};
// 上传 base64 图片到 runninghub
const uploadBase64ToRunninghub = async (base64Image: string, apiKey: string, baseURL: string): Promise<string> => {
try {
apiKey = apiKey.replace("Bearer ", "");
// 移除 base64 前缀
const base64Data = base64Image.replace(/^data:image\/\w+;base64,/, "");
let buffer = Buffer.from(base64Data, "base64");
// 压缩图片到 5MB 以下
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
if (buffer.length > MAX_SIZE) {
let quality = 90;
while (buffer.length > MAX_SIZE && quality > 10) {
const compressed = await sharp(buffer).jpeg({ quality, mozjpeg: true }).toBuffer();
buffer = Buffer.from(compressed);
quality -= 10;
}
// 如果仍然超过限制,进一步调整尺寸
if (buffer.length > MAX_SIZE) {
const metadata = await sharp(buffer).metadata();
const scale = Math.sqrt(MAX_SIZE / buffer.length);
const resized = await sharp(buffer)
.resize({
width: Math.floor((metadata.width || 1920) * scale),
height: Math.floor((metadata.height || 1080) * scale),
fit: "inside",
})
.jpeg({ quality: 80, mozjpeg: true })
.toBuffer();
buffer = Buffer.from(resized);
}
}
// 创建 FormData
const formData = new FormData();
formData.append("file", buffer, {
filename: "image.jpg",
contentType: "image/jpeg",
});
// 上传图片
const uploadRes = await axios.post(`https://www.runninghub.cn/openapi/v2/media/upload/binary`, formData, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (uploadRes.data.code !== 0 || !uploadRes.data.data?.download_url) {
throw new Error(`图片上传失败: ${JSON.stringify(uploadRes.data)}`);
}
return uploadRes.data.data.download_url;
} catch (error) {
console.error("上传图片时发生错误:", error);
throw error;
}
};
const generateVideoWithConfig = async (config: VideoConfig, configItem: { model: string; apiKey: string; baseURL: string; manufacturer: string }) => {
const { apiKey, baseURL, manufacturer, model } = configItem;
const imageArrPath = [];
for (const imageVal of config?.imageBase64!) {
// 判断是否为base64串
const isBase64 = typeof imageVal === "string" && /^data:image\/[a-zA-Z0-9\+\-\.]+;base64,[\s\S]+$/.test(imageVal.trim());
if (isBase64) {
imageArrPath.push(imageVal);
} else {
const base64 = await urlToBase64(imageVal);
imageArrPath.push(base64);
}
}
config.imageBase64 = imageArrPath;
let videoUrl: string | null = null;
if (manufacturer === "volcengine") {
const doubaoConfig = config as DoubaoVideoConfig;
const createRes = await axios.post(
baseURL ?? "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks",
{
model: "doubao-seedance-1-5-pro-251215",
content: [
{ type: "text", text: config.prompt },
...(doubaoConfig.imageBase64
? doubaoConfig.imageBase64.map((base64, i) => ({
type: "image_url",
image_url: { url: base64 },
role: i === 0 ? "first_frame" : "last_frame",
}))
: []),
],
generate_audio: doubaoConfig.audio ?? false,
duration: doubaoConfig.duration,
resolution: doubaoConfig.aspectRatio,
watermark: false,
},
{ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` } },
);
const taskId = createRes.data.id;
if (!taskId) throw new Error("视频任务创建失败");
videoUrl = await pollTask(async () => {
const res = await axios.get(`${baseURL ?? "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks"}/${taskId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const { status, content } = res.data;
if (status === "succeeded") return { completed: true, imageUrl: content?.video_url };
if (["failed", "cancelled", "expired"].includes(status)) return { completed: false, error: `任务${status}` };
if (["queued", "running"].includes(status)) return { completed: false };
return { completed: false, error: `未知状态: ${status}` };
});
} else if (manufacturer === "runninghub") {
const runninghubConfig = config as RunninghubVideoConfig;
// 如果有图片,先上传
let uploadedImageUrl: string | undefined;
if (runninghubConfig.imageBase64 && runninghubConfig.imageBase64.length > 0) {
uploadedImageUrl = await uploadBase64ToRunninghub(runninghubConfig.imageBase64[0]!, apiKey ?? "", "https://www.runninghub.cn");
}
const endpoint = uploadedImageUrl ? "/openapi/v2/rhart-video-s/image-to-video" : "/openapi/v2/rhart-video-s/text-to-video";
const requestBody = uploadedImageUrl
? {
prompt: config.prompt,
imageUrl: uploadedImageUrl,
duration: String(runninghubConfig.duration) as "10" | "15",
aspectRatio: runninghubConfig.aspectRatio,
}
: { prompt: config.prompt, model };
const createRes = await axios.post(`https://www.runninghub.cn${endpoint}`, requestBody, {
headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json" },
});
const { taskId, status: initialStatus, errorMessage } = createRes.data;
if (!taskId) throw new Error(`视频任务创建失败: ${errorMessage || "未知错误"}`);
if (initialStatus === "FAILED") throw new Error(`任务创建失败: ${errorMessage}`);
videoUrl = await pollTask(async () => {
const res = await axios.post(
`https://www.runninghub.cn/task/openapi/outputs`,
{ apiKey: apiKey?.replace("Bearer ", ""), taskId },
{ headers: { Authorization: "Bearer " + apiKey } },
);
const { code, msg, data } = res.data;
// 成功完成
if (code === 0 && msg === "success" && data?.[0]?.fileUrl) {
return { completed: true, imageUrl: data[0].fileUrl };
}
// 进行中
if (code === 804 || code === 813) {
return { completed: false };
}
// 失败
if (code === 805) {
const failedReason = data?.[0]?.failedReason;
let errorMsg = "未知原因";
if (failedReason) {
// 尝试多种可能的错误信息字段
errorMsg =
failedReason.exception_message ||
failedReason.exceptionMessage ||
failedReason.message ||
failedReason.reason ||
JSON.stringify(failedReason);
}
return {
completed: false,
error: `任务失败: ${errorMsg}`,
};
}
// 其他未知状态
return {
completed: false,
error: `未知状态: code=${code}, msg=${msg}, data=${JSON.stringify(data)}`,
};
});
} else if (manufacturer === "openAi") {
const openaiConfig = config as OpenAIVideoConfig;
// 如果有图片,先上传
let uploadedImageUrl: string | undefined;
if (openaiConfig.imageBase64 && openaiConfig.imageBase64.length) {
const base64Data = openaiConfig.imageBase64[0]!.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
const formData = new FormData();
formData.append("file", buffer, { filename: "image.jpg", contentType: "image/jpeg" });
const uploadRes = await axios.post(`${baseURL}/videos`, formData, {
headers: {
Authorization: `Bearer ${apiKey}`,
...formData.getHeaders(),
},
});
uploadedImageUrl = uploadRes.data?.id || uploadRes.data?.url;
}
// 创建视频生成任务
const formData = new FormData();
formData.append("model", model);
formData.append("prompt", config.prompt);
formData.append("seconds", String(openaiConfig.duration));
// 根据 aspectRatio 设置 size
const sizeMap: Record<string, string> = {
"16:9": "1920x1080",
"9:16": "1080x1920",
"1:1": "1080x1080",
"4:3": "1440x1080",
"3:4": "1080x1440",
"21:9": "2560x1080",
};
formData.append("size", sizeMap[openaiConfig.aspectRatio] || "1920x1080");
if (uploadedImageUrl) {
formData.append("input_reference", uploadedImageUrl);
}
const createRes = await axios.post(`${baseURL}/videos`, formData, {
headers: {
Authorization: `Bearer ${apiKey}`,
...formData.getHeaders(),
},
});
const taskId = createRes.data?.id;
if (!taskId) throw new Error("视频任务创建失败");
// 轮询任务状态
videoUrl = await pollTask(async () => {
const res = await axios.get(`${baseURL}/videos/${taskId}`, {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
const { status, imageUrl, failReason } = res.data;
if (status === "SUCCESS") return { completed: true, imageUrl };
if (status === "FAILURE" || status === "CANCEL") {
return { completed: false, error: `任务${status}: ${failReason || "未知原因"}` };
}
if (["NOT_START", "SUBMITTED", "IN_PROGRESS", "MODAL"].includes(status)) {
return { completed: false };
}
return { completed: false, error: `未知状态: ${status}` };
});
} else if (manufacturer === "apimart") {
// apimart 视频生成
const apimartConfig = config as OpenAIVideoConfig;
const apimartBaseURL = "https://api.apimart.ai";
// 上传图片到 apimart 图床
let imageUrls: string[] = [];
if (apimartConfig.imageBase64 && apimartConfig.imageBase64.length > 0) {
for (const base64Image of apimartConfig.imageBase64) {
// 如果已经是 URL直接使用
if (base64Image.startsWith("http")) {
imageUrls.push(base64Image);
continue;
}
// 获取预签名 URL
const presignRes = await axios.post(
"https://apimart.ai/api/upload/presign",
{ contentType: "image/jpeg", fileExtension: "jpeg", permanent: false },
{ headers: { "Content-Type": "application/json" } },
);
if (!presignRes.data.success || !presignRes.data.presignedUrl || !presignRes.data.cdnUrl) {
throw new Error(`获取预签名 URL 失败: ${JSON.stringify(presignRes.data)}`);
}
const { presignedUrl, cdnUrl } = presignRes.data;
// 移除 base64 前缀并转为 buffer
const base64Data = base64Image.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// 上传图片到预签名 URL
await axios.put(presignedUrl, buffer, {
headers: { "Content-Type": "image/jpeg" },
});
imageUrls.push(cdnUrl);
}
}
// 创建视频生成任务
const requestBody: {
model: string;
prompt: string;
duration: number;
aspect_ratio: string;
image_urls?: string[];
} = {
model: model || "sora-2",
prompt: config.prompt,
duration: apimartConfig.duration,
aspect_ratio: apimartConfig.aspectRatio,
};
if (imageUrls.length > 0) {
requestBody.image_urls = imageUrls;
}
const createRes = await axios.post(`${apimartBaseURL}/v1/videos/generations`, requestBody, {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (createRes.data.code !== 200 || !createRes.data.data?.[0]?.task_id) {
const errorMsg = createRes.data.error?.message || JSON.stringify(createRes.data);
throw new Error(`视频任务创建失败: ${errorMsg}`);
}
const taskId = createRes.data.data[0].task_id;
// 轮询任务状态
videoUrl = await pollTask(async () => {
const res = await axios.get(`${apimartBaseURL}/v1/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
params: { language: "en" },
});
// 检查是否有错误
if (res.data.error) {
return {
completed: false,
error: `查询失败: ${res.data.error.message || JSON.stringify(res.data.error)}`,
};
}
if (res.data.code !== 200) {
return { completed: false, error: `查询失败: ${JSON.stringify(res.data)}` };
}
const { status, result } = res.data.data;
if (status === "completed") {
// 获取视频 URL
const videoUrlResult = result?.videos?.[0]?.url?.[0];
return { completed: true, imageUrl: videoUrlResult };
}
if (status === "failed" || status === "cancelled") {
return { completed: false, error: `任务${status}` };
}
// 其他状态submitted, processing 等)继续轮询
return { completed: false };
});
} else {
throw new Error(`不支持的厂商: ${manufacturer}`);
}
return videoUrl;
};
export default async (config: VideoConfig, manufacturer: string) => {
if (!config.imageBase64 || config.imageBase64.length <= 0) throw new Error("未传图片");
const configItem = await u.getConfig("video", manufacturer);
if (!configItem) {
throw new Error("未找到任何视频配置");
}
let lastError: Error | null = null;
// for (const configItem of configList) {
// 每个配置项重试1次共2次尝试
for (let attempt = 0; attempt < 2; attempt++) {
try {
const videoUrl = await generateVideoWithConfig(config, configItem);
if (videoUrl) {
const response = await axios.get(videoUrl, { responseType: "stream" });
await u.oss.writeFile(config.savePath, response.data);
return config.savePath;
}
return videoUrl;
} catch (error: any) {
lastError = error as Error;
console.warn(`配置 ${configItem.model}${attempt + 1} 次尝试失败:`, error?.response?.data || error.message);
// 如果是第一次尝试失败,继续重试
if (attempt === 0) continue;
// 第二次也失败了,跳到下一个配置项
break;
}
}
// }
// 所有配置都失败了
throw new Error(`所有视频配置都失败了。最后一次错误: ${lastError?.message || "未知错误"}`);
};