438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
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 || "未知错误"}`);
|
||
};
|