diff --git a/src/utils/ai/image/adapter/volcengine.ts b/src/utils/ai/image/adapter/volcengine.ts new file mode 100644 index 0000000..bf131a3 --- /dev/null +++ b/src/utils/ai/image/adapter/volcengine.ts @@ -0,0 +1,35 @@ +import "../type"; + +export function buildReqBody(input: ImageConfig, config: AIConfig) { + const size = input.size === "1K" ? "2K" : input.size; + const sizeMap: Record> = { + "16:9": { + "2K": "2848x1600", + "4K": "4096x2304", + }, + "9:16": { + "2K": "1600x2848", + "4K": "2304x4096", + }, + }; + const fullPrompt = input.systemPrompt ? `${input.systemPrompt}\n\n${input.prompt}` : input.prompt; + + const requestBody: Record = { + model: config.model, + prompt: fullPrompt, + size: sizeMap[input.aspectRatio][size], + response_format: "url", + sequential_image_generation: "disabled", + stream: false, + watermark: false, + ...(input.imageBase64 && { image: input.imageBase64 }), + }; + + return requestBody; +} + +export function buildReqUrl(baseUrl: string) { + return { + requestUrl: `${baseUrl}/v1/images/generations`, + }; +} diff --git a/src/utils/ai/image/owned/formal.ts b/src/utils/ai/image/owned/formal.ts index 5b21411..3b0f4d0 100644 --- a/src/utils/ai/image/owned/formal.ts +++ b/src/utils/ai/image/owned/formal.ts @@ -4,16 +4,10 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { pollTask } from "@/utils/ai/utils"; import u from "@/utils"; import axios from "axios"; -function getApiUrl(apiUrl: string) { - if (apiUrl.includes("|")) { - const parts = apiUrl.split("|"); - if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) { - throw new Error("url 格式错误,请使用 url1|url2 格式"); - } - return { requestUrl: parts[0].trim(), queryUrl: parts[1].trim() }; - } - throw new Error("请填写正确的url"); -} +import * as volcengine from "../adapter/volcengine"; +const modelFn = { + volcengine, +} as const; function template(replaceObj: Record, url: string) { return url.replace(/\{(\w+)\}/g, (match, varName) => { return replaceObj.hasOwnProperty(varName) ? replaceObj[varName] : match; @@ -23,83 +17,37 @@ export default async (input: ImageConfig, config: AIConfig): Promise => if (!config.model) throw new Error("缺少Model名称"); if (!config.apiKey) throw new Error("缺少API Key"); - const defaultBaseURL = "http://192.168.0.74:3000/imagegenerator/task|http://192.168.0.74:3000/imagegenerator/task/{id}"; - const { requestUrl, queryUrl } = getApiUrl(config.baseURL! ?? defaultBaseURL); - // 根据 size 配置映射到具体尺寸 - const sizeMap: Record> = { - "1K": { - "16:9": "1664x928", - "9:16": "928x1664", - }, - "2K": { - "16:9": "2048x1152", - "9:16": "1152x2048", - }, - "4K": { - "16:9": "2048x1152", - "9:16": "1328*1328", - }, - }; - const modelSizeMap = { - "Qwen-Image": { - "16:9": "1664*928", - "9:16": "928*1664", - }, - "Z-Image-Turbo": { - "16:9": "1024*768", - "9:16": "768*1024", - }, - }; - // 构建完整的提示词 - const fullPrompt = input.systemPrompt ? `${input.systemPrompt}\n\n${input.prompt}` : input.prompt; - - let mergedImage = input.imageBase64; - if (mergedImage && mergedImage.length) { - const smallImage = await u.imageTools.mergeImages(mergedImage, "5mb"); - mergedImage = [smallImage]; - } - - const size = modelSizeMap?.[config.model]?.[input.size]?.[input.aspectRatio] ?? modelSizeMap?.[config.model]?.[input.size] ?? "1024*1024"; - - const taskBody: Record = { - model: config.model, - input: { - prompt: fullPrompt, - ...(input.imageBase64 && input.imageBase64.length ? { images: input.imageBase64 } : {}), - }, - parameters: { - size:"1600*2848", - }, - // negative_prompt: "", - }; + const { requestUrl, queryUrl = null } = modelFn["volcengine"].buildReqUrl("http://192.168.0.74:33332"); + const taskBody = modelFn["volcengine"].buildReqBody(input, config); const apiKey = config.apiKey.replace("Bearer ", ""); try { const { data } = await axios.post(requestUrl, taskBody, { headers: { Authorization: `Bearer ${apiKey}` } }); - console.log("%c Line:70 🥪 data", "background:#ed9ec7", data); - if (data.code != "success") throw new Error(`任务提交失败: ${data || "未知错误"}`); - const taskId = data.data; + if (queryUrl) { + if (data.code != "success") throw new Error(`任务提交失败: ${data || "未知错误"}`); + const taskId = data.data; - return await pollTask(async () => { - const { data: queryData } = await axios.get(template({ id: taskId }, queryUrl), { - headers: { Authorization: `Bearer ${apiKey}` }, + return await pollTask(async () => { + const { data: queryData } = await axios.get(template({ id: taskId }, queryUrl), { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + const { status, result_url, fail_reason } = queryData.data || {}; + + if (status === "FAILURE") { + return { completed: false, error: fail_reason ?? "图片生成失败" }; + } + + if (status === "SUCCESS") { + return { completed: true, url: result_url }; + } + + return { completed: false }; }); - console.log("%c Line:77 🍧 data", "background:#f5ce50", data); - console.log("%c Line:76 🥑 queryData", "background:#2eafb0", queryData); - - const { status, result_url, fail_reason } = queryData.data || {}; - - if (status === "FAILURE") { - return { completed: false, error: fail_reason ?? "图片生成失败" }; - } - - if (status === "SUCCESS") { - return { completed: true, url: result_url }; - } - - return { completed: false }; - }); + } else { + return data.data[0]?.url; + } } catch (error: any) { const msg = u.error(error).message || "图片生成失败"; throw new Error(msg); diff --git a/src/utils/ai/video/adapter/openai.ts b/src/utils/ai/video/adapter/openai.ts new file mode 100644 index 0000000..dcb10fb --- /dev/null +++ b/src/utils/ai/video/adapter/openai.ts @@ -0,0 +1,39 @@ +import sharp from "sharp"; +import "../type"; +import FormData from "form-data"; + +export async function buildReqBody(input: VideoConfig, config: AIConfig) { + const sizeMap: Record = { + "16:9": "1280x720", + "9:16": "720x1280", + }; + const formData = new FormData(); + formData.append("model", config.model!); + formData.append("prompt", input.prompt); + formData.append("seconds", String(input.duration)); + + const size = sizeMap[input.aspectRatio] || "1280x720"; + formData.append("size", size); + if (input.imageBase64 && input.imageBase64.length) { + const base64Data = input.imageBase64[0]!.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + + // 解析尺寸 + const [width, height] = size.split("x").map(Number); + + // 使用 sharp 调整图片尺寸 + const resizedBuffer = await sharp(buffer).resize(width, height, { fit: "cover" }).jpeg({ quality: 100 }).toBuffer(); + + formData.append("input_reference", resizedBuffer, { filename: "image.jpg", contentType: "image/jpeg" }); + } + + return formData; +} + +export function buildReqUrl(baseUrl: string): { requestUrl: string; queryUrl: string; downLoadUrl: string } { + return { + requestUrl: `${baseUrl}/v1/videos`, + queryUrl: `${baseUrl}/v1/videos/{id}`, + downLoadUrl: `${baseUrl}/v1/videos/{id}/content`, + }; +} diff --git a/src/utils/ai/video/adapter/vidu.ts b/src/utils/ai/video/adapter/vidu.ts new file mode 100644 index 0000000..ece3e3f --- /dev/null +++ b/src/utils/ai/video/adapter/vidu.ts @@ -0,0 +1,23 @@ +import "../type"; + +export function buildReqBody(input: VideoConfig, config: AIConfig) { + const requestBody: any = { + model: config.model, + ...(input.imageBase64 && input.imageBase64.length ? { images: input.imageBase64 } : {}), + prompt: input.prompt, + duration: input.duration, + resolution: input.resolution, + audio: input?.audio ?? false, + aspect_ratio: input.aspectRatio, + off_peak: false, + }; + + return requestBody; +} + +export function buildReqUrl(baseUrl: string): { requestUrl: string; queryUrl: string } { + return { + requestUrl: `${baseUrl}/v1/video/generations`, + queryUrl: `${baseUrl}/v1/video/generations/{id}`, + }; +} diff --git a/src/utils/ai/video/adapter/volcengine.ts b/src/utils/ai/video/adapter/volcengine.ts new file mode 100644 index 0000000..f969dc9 --- /dev/null +++ b/src/utils/ai/video/adapter/volcengine.ts @@ -0,0 +1,53 @@ +import "../type"; + +export function buildReqBody(input: VideoConfig, config: AIConfig) { + const hasStartEndType = input.mode === "startEnd"; + const images = input.imageBase64 || []; + // 判断是否为首尾帧模式(需要两张图且类型支持首尾帧) + const isStartEndMode = images.length === 2 && hasStartEndType; + + // 构建图片内容 + const imageContent = images.map((base64, index) => { + const item: Record = { + type: "image_url", + image_url: { url: base64 }, + }; + if (isStartEndMode) { + item.role = index === 0 ? "first_frame" : "last_frame"; + } + return item; + }); + + // // 构建请求体 + // const requestBody: Record = { + // model: config.model, + // content: [{ type: "text", text: input.prompt }, ...imageContent], + // duration: input.duration, + // resolution: input.resolution, + // watermark: false, + // }; + const requestBody: any = { + model: config.model, + ...(input.imageBase64 && input.imageBase64.length ? { images: input.imageBase64 } : {}), + prompt: input.prompt, + duration: input.duration, + size: input.resolution, + metadata: { + generate_audio: input?.audio ?? false, + image_roles: ["first_frame", "last_frame"], + }, + }; + + // // 仅当模型支持音频时才添加 generate_audio 字段 + // if (typeof input.audio == "boolean") { + // requestBody.generate_audio = input.audio ?? false; + // } + return requestBody; +} + +export function buildReqUrl(baseUrl: string): { requestUrl: string; queryUrl: string } { + return { + requestUrl: `${baseUrl}/v1/video/generations`, + queryUrl: `${baseUrl}/v1/video/generations/{id}`, + }; +} diff --git a/src/utils/ai/video/adapter/wan.ts b/src/utils/ai/video/adapter/wan.ts new file mode 100644 index 0000000..9c39603 --- /dev/null +++ b/src/utils/ai/video/adapter/wan.ts @@ -0,0 +1,62 @@ +import "../type"; + +export function buildReqBody(input: VideoConfig, config: AIConfig) { + const images = input.imageBase64 || []; + + // 构建图片内容 + const imageContent = images.map((base64, index) => { + const item: Record = { + type: "image_url", + image: { url: base64 }, + }; + return item; + }); + const sizeMap: Record> = { + "480p": { + "16:9": "832*480", + "9:16": "480*832", + }, + "720p": { + "16:9": "1280*720", + "9:16": "720*1280", + }, + "1080p": { + "16:9": "1920*1080", + "9:16": "1080*1920", + }, + }; + const hasStartEnd = input.mode == "startEnd"; + console.log("%c Line:29 🎂 hasStartEnd", "background:#2eafb0", hasStartEnd); + const imageReq: Record = {}; + if (hasStartEnd && Array.isArray(images) && images.length) { + if (images[0]) imageReq.img_url = images[0]; + if (images[1]) imageReq.last_frame_url = images[1]; + } else if (!hasStartEnd && Array.isArray(images) && images[0]) { + console.log("%c Line:35 🍤", "background:#f5ce50"); + imageReq.img_url = images[0]; + } + + const resolutionKey = input.resolution.toLowerCase(); + console.log("%c Line:43 🍑 resolutionKey", "background:#e41a6a", resolutionKey); + const size = sizeMap[resolutionKey]?.[input.aspectRatio]; + + const requestBody: any = { + model: config.model, + ...(imageReq?.img_url ? { input_reference: imageReq.img_url } : {}), + prompt: input.prompt, + duration: input.duration, + size: !images.length ? size : input.resolution.toUpperCase(), + metadata: { + ...imageReq, + audio: input?.audio ?? false, + }, + }; + return requestBody; +} + +export function buildReqUrl(baseUrl: string): { requestUrl: string; queryUrl: string } { + return { + requestUrl: `${baseUrl}/v1/video/generations`, + queryUrl: `${baseUrl}/v1/video/generations/{id}`, + }; +} diff --git a/src/utils/ai/video/modelList.ts b/src/utils/ai/video/modelList.ts index 14c65aa..5326d8e 100644 --- a/src/utils/ai/video/modelList.ts +++ b/src/utils/ai/video/modelList.ts @@ -159,15 +159,6 @@ const modelList: Owned[] = [ audio: false, }, // ================== ViduQ3系列 ================== - // viduq3-pro 文生视频 - { - manufacturer: "vidu", - model: "viduq3-pro", - durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], resolution: ["540p", "720p", "1080p"] }], - aspectRatio: ["16:9", "9:16", "3:4", "4:3", "1:1"], - type: ["text"], - audio: true, - }, // viduq3-pro 图生视频 { manufacturer: "vidu", @@ -187,14 +178,6 @@ const modelList: Owned[] = [ audio: false, }, // viduq2-pro 文生视频 - { - manufacturer: "vidu", - model: "viduq2-pro", - durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], resolution: ["540p", "720p", "1080p"] }], - aspectRatio: ["16:9", "9:16", "3:4", "4:3", "1:1"], - type: ["text"], - audio: false, - }, // viduq2-pro 图生视频 { manufacturer: "vidu", @@ -205,14 +188,6 @@ const modelList: Owned[] = [ audio: false, }, // viduq2-turbo 文生视频 - { - manufacturer: "vidu", - model: "viduq2-turbo", - durationResolutionMap: [{ duration: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], resolution: ["540p", "720p", "1080p"] }], - aspectRatio: ["16:9", "9:16", "3:4", "4:3", "1:1"], - type: ["text"], - audio: false, - }, // viduq2-turbo 图生视频 { manufacturer: "vidu", @@ -223,14 +198,6 @@ const modelList: Owned[] = [ audio: false, }, // viduq1 文生视频 - { - manufacturer: "vidu", - model: "viduq1", - durationResolutionMap: [{ duration: [5], resolution: ["1080p"] }], - aspectRatio: ["16:9", "9:16", "1:1"], - type: ["text"], - audio: false, - }, // viduq1 图生视频 { manufacturer: "vidu", @@ -465,7 +432,7 @@ const modelList: Owned[] = [ type: ["singleImage", "text"], audio: false, }, - // ================== Apimart 系列 ================== + // ================== Apimart 系列 ================== // sora { manufacturer: "apimart", diff --git a/src/utils/ai/video/owned/formal.ts b/src/utils/ai/video/owned/formal.ts index c4b540c..694334a 100644 --- a/src/utils/ai/video/owned/formal.ts +++ b/src/utils/ai/video/owned/formal.ts @@ -1,18 +1,74 @@ import "../type"; -import { generateImage, generateText, ModelMessage } from "ai"; -import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { pollTask } from "@/utils/ai/utils"; import u from "@/utils"; import axios from "axios"; -function getApiUrl(apiUrl: string) { - if (apiUrl.includes("|")) { - const parts = apiUrl.split("|"); - if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) { - throw new Error("url 格式错误,请使用 url1|url2 格式"); - } - return { requestUrl: parts[0].trim(), queryUrl: parts[1].trim() }; +import path from "path"; + +import * as volcengine from "../adapter/volcengine"; +import * as openai from "../adapter/openai"; +import * as vidu from "../adapter/vidu"; +import * as wan from "../adapter/wan"; + +// 适配器映射 +const modelFn = { + volcengine, + vidu, + openai, + wan, +} as const; + +// 模型名称到适配器的映射(精确匹配) +const modelMapping: Record = { + // Volcengine 火山引擎模型 + "doubao-seedance-1-5-pro-251215": "volcengine", + "doubao-seedance-1-0-pro-250528": "volcengine", + "Seedance-2.0": "volcengine", + // Vidu 模型 + ViduQ2: "vidu", + "ViduQ2-turbo": "vidu", + "ViduQ2-pro": "vidu", + "ViduQ3-pro": "vidu", + // OpenAI 模型 + sora2: "openai", + "sora2-pro": "openai", + "gpt-video": "openai", + // 万象/Wan 模型 + "Wan2.6-T2V": "wan", + "Wan2.6-I2V": "wan", +}; + +// 模型名称关键字到适配器的映射(模糊匹配) +const modelKeywords: Array<{ keywords: string[]; adapter: keyof typeof modelFn }> = [ + { keywords: ["doubao", "volcengine", "seedance"], adapter: "volcengine" }, + { keywords: ["vidu"], adapter: "vidu" }, + { keywords: ["sora", "openai", "gpt"], adapter: "openai" }, + { keywords: ["wan", "wanx"], adapter: "wan" }, +]; + +/** + * 根据模型名称获取对应的适配器 + */ +function getModelAdapter(modelName: string) { + // 1. 先尝试精确匹配 + const exactMatch = modelMapping[modelName.toLowerCase()]; + if (exactMatch) { + return modelFn[exactMatch]; } - throw new Error("请填写正确的url"); + + // 2. 尝试关键字模糊匹配 + const lowerModelName = modelName.toLowerCase(); + for (const { keywords, adapter } of modelKeywords) { + if (keywords.some((kw) => lowerModelName.includes(kw.toLowerCase()))) { + return modelFn[adapter]; + } + } + + // 3. 如果模型名称本身就是适配器名称 + if (modelName in modelFn) { + return modelFn[modelName as keyof typeof modelFn]; + } + + return modelFn["wan"]; } function template(replaceObj: Record, url: string) { return url.replace(/\{(\w+)\}/g, (match, varName) => { @@ -23,77 +79,78 @@ export default async (input: VideoConfig, config: AIConfig): Promise => if (!config.model) throw new Error("缺少Model名称"); if (!config.apiKey) throw new Error("缺少API Key"); - const defaultBaseURL = "http://192.168.0.74:3000/videogenerator/generate|http://192.168.0.74:3000/videogenerator/generate/{id}"; - const { requestUrl, queryUrl } = getApiUrl(config.baseURL! ?? defaultBaseURL); - // 根据 size 配置映射到具体尺寸 - const sizeMap: Record> = { - "480P": { - "16:9": "832*480", - "9:16": "480*332", - }, - "720P": { - "16:9": "1280*720", - "9:16": "720*1280", - }, - "1080P": { - "16:9": "1920*1080", - "9:16": "1080*1920", - }, - }; - // 构建完整的提示词 - let mergedImage = input.imageBase64; - if (mergedImage && mergedImage.length) { - const smallImage = await u.imageTools.mergeImages(mergedImage, "5mb"); - mergedImage = [smallImage]; - } + // 根据模型名称获取对应的适配器 + const modelAdapter = getModelAdapter(config.model); - const size = sizeMap[input.resolution]?.[input.aspectRatio] ?? "1280*720"; - const imageCount: { type: string; image_url: string }[] = []; - if (input.imageBase64 && input.imageBase64.length) { - input.imageBase64.forEach((i, index) => { - imageCount.push({ - type: "image_url", - image_url: { url: i }, - role: index === 0 ? "first_frame" : "last_frame", - }); - }); - } - const taskBody: Record = { - model: config.model, - content: [ - { - type: "text", - text: input.prompt, - }, - ...imageCount, - ], - // parameters: { - // aspect_ratio: input.aspectRatio, - // size: input.resolution, - // duration: input.duration, - // }, - // ...(typeof input.audio === "boolean" ? { generate_audio: input.audio } : {}), - }; - console.log("%c Line:62 🥑 taskBody", "background:#ea7e5c", taskBody); + const { requestUrl, queryUrl, downLoadUrl = null } = modelAdapter.buildReqUrl("http://192.168.0.74:33332"); + const taskBody = await modelAdapter.buildReqBody(input, config); const apiKey = config.apiKey.replace("Bearer ", ""); try { const { data } = await axios.post(requestUrl, taskBody, { headers: { Authorization: `Bearer ${apiKey}` } }); - console.log("%c Line:70 🥪 data", "background:#ed9ec7", data); - console.log("%c Line:84 🍐 data.code != uccess", "background:#e41a6a", data.code != "success"); - console.log("%c Line:83 🍇 data.code", "background:#b03734", data.code); + console.log("%c Line:91 🌽 data", "background:#3f7cff", data); - if (data.code != "success") throw new Error(`任务提交失败: ${data || "未知错误"}`); - const taskId = data.data; + const taskId = data.id ?? data.taskId ?? data.task_id ?? data.data; + + if (!taskId) throw new Error(`任务提交失败: ${data ? JSON.stringify(data) : "未知错误"}`); return await pollTask(async () => { const { data: queryData } = await axios.get(template({ id: taskId }, queryUrl), { headers: { Authorization: `Bearer ${apiKey}` }, }); - console.log("%c Line:77 🍧 data", "background:#f5ce50", queryData); + console.log("%c Line:99 🥝 queryData", "background:#e41a6a", queryData); - const { status, result_url, fail_reason } = queryData.data || {}; + // const { status, result_url, fail_reason } = queryData.data || {}; + const status = queryData?.status ?? queryData?.data?.status; + const result_url = queryData?.metadata?.url ?? queryData?.data?.result_url; + const fail_reason = queryData?.data?.fail_reason ?? queryData?.data; + + switch (status) { + case "completed": + case "SUCCESS": + case "success": + if (downLoadUrl) { + // 下载视频,带重试机制 + let videoRes; + let retries = 3; + let lastError; + + for (let i = 0; i < retries; i++) { + try { + // 构建下载URL + const finalDownloadUrl = downLoadUrl + ? template({ id: taskId }, downLoadUrl) + : queryData.video_url || queryData.url || queryData.metadata.url; // 从响应中获取视频URL + + videoRes = await axios.get(finalDownloadUrl, { + headers: { Authorization: `Bearer ${apiKey}` }, + responseType: "arraybuffer", + timeout: 60 * 1000 * 10, // 60秒超时 + }); + break; // 成功则跳出循环 + } catch (error) { + lastError = error; + console.error(`视频下载失败,第 ${i + 1}/${retries} 次尝试:`, error); + if (i < retries - 1) { + // 等待后重试,使用指数退避 + await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1000)); + } + } + } + if (!videoRes) { + throw new Error(`视频下载失败,已重试 ${retries} 次: ${lastError}`); + } + + // 将视频buffer转换为base64或直接返回buffer + const savePath = input.savePath.endsWith(".mp4") ? input.savePath : path.join(input.savePath, `other_${Date.now()}.mp4`); + await u.oss.writeFile(input.savePath, videoRes.data); + + return { completed: true, url: savePath }; + } else { + return { completed: true, url: result_url }; + } + } if (status === "FAILURE") { return { completed: false, error: fail_reason ? fail_reason : "视频生成失败" }; } @@ -105,16 +162,8 @@ export default async (input: VideoConfig, config: AIConfig): Promise => return { completed: false }; }); } catch (error: any) { - console.log("%c Line:105 🍖 error", "background:#ed9ec7", error); const msg = u.error(error).message || "图片生成失败"; - console.log("%c Line:107 🌽 u.error(error)", "background:#ea7e5c", u.error(error)); + throw new Error(msg); } }; - -async function urlToBase64(url: string): Promise { - 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}`; -} diff --git a/src/utils/ai/video/owned/other copy.ts b/src/utils/ai/video/owned/other copy.ts new file mode 100644 index 0000000..226a21b --- /dev/null +++ b/src/utils/ai/video/owned/other copy.ts @@ -0,0 +1,146 @@ +import "../type"; +import axios from "axios"; +import sharp from "sharp"; +import FormData from "form-data"; +import fs from "fs"; +import path from "path"; +import u from "@/utils"; + +import { pollTask, validateVideoConfig } from "@/utils/ai/utils"; +function template(replaceObj: Record, url: string) { + return url.replace(/\{(\w+)\}/g, (match, varName) => { + return replaceObj.hasOwnProperty(varName) ? replaceObj[varName] : match; + }); +} +export default async (input: VideoConfig, config: AIConfig) => { + if (!config.apiKey) throw new Error("缺少API Key"); + if (!config.baseURL) throw new Error("缺少baseURL"); + // const { owned, images, hasTextType } = validateVideoConfig(input, config); + + const authorization = `Bearer ${config.apiKey}`; + const urls = config.baseURL.split("|"); + const isThreeUrlMode = urls.length === 3; + console.log("%c Line:24 🌭 isThreeUrlMode", "background:#ed9ec7", isThreeUrlMode); + + let requestUrl: string, queryUrl: string, downLoadUrl: string | undefined; + + if (isThreeUrlMode) { + [requestUrl, queryUrl, downLoadUrl] = urls; + } else { + [requestUrl, queryUrl] = urls; + } + + // 根据 aspectRatio 设置 size + const sizeMap: Record = { + "16:9": "1280x720", + "9:16": "720x1280", + }; + let resData; + let taskId = ""; + if (isThreeUrlMode) { + // 三个地址:使用 FormData 方式 + const formData = new FormData(); + formData.append("model", config.model); + formData.append("prompt", input.prompt); + formData.append("seconds", String(input.duration)); + + const size = sizeMap[input.aspectRatio] || "1280x720"; + formData.append("size", size); + + if (input.imageBase64 && input.imageBase64.length) { + const base64Data = input.imageBase64[0]!.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + + // 解析尺寸 + const [width, height] = size.split("x").map(Number); + + // 使用 sharp 调整图片尺寸 + const resizedBuffer = await sharp(buffer).resize(width, height, { fit: "cover" }).jpeg({ quality: 90 }).toBuffer(); + + formData.append("input_reference", resizedBuffer, { filename: "image.jpg", contentType: "image/jpeg" }); + } + + const response = await axios.post(requestUrl, formData, { + headers: { Authorization: authorization, ...formData.getHeaders() }, + }); + + taskId = response.data?.task_id || response.data?.id; + resData = response.data; + } else { + // 两个地址:使用 JSON 方式 + + const requestBody: any = { + model: config.model, + prompt: input.prompt, + aspect_ratio: input.aspectRatio || "16:9", + size: "720p", + }; + + if (input.imageBase64 && input.imageBase64.length) { + requestBody.images = input.imageBase64; + } + + const response = await axios.post(requestUrl, JSON.stringify(requestBody), { + headers: { + Authorization: authorization, + "Content-Type": "application/json", + }, + }); + taskId = response.data.id; + resData = response.data; + } + console.log("%c Line:87 🥒 taskId", "background:#f5ce50", taskId); + + if (!taskId) throw new Error(`任务提交失败: ${resData ? JSON.stringify(resData) : "未知错误"}`); + + return await pollTask(async () => { + // 构建查询URL,两个地址模式时使用URL参数 + const finalQueryUrl = isThreeUrlMode ? template({ id: taskId }, queryUrl) : `${queryUrl}?id=${taskId}`; + + const { data: queryData } = await axios.get(finalQueryUrl, { + headers: { Authorization: authorization }, + }); + console.log("%c Line:100 🥑 queryData", "background:#42b983", queryData); + + if (queryData.status === "completed") { + // 下载视频,带重试机制 + let videoRes; + let retries = 3; + let lastError; + + for (let i = 0; i < retries; i++) { + try { + // 构建下载URL + const finalDownloadUrl = isThreeUrlMode && downLoadUrl ? template({ id: taskId }, downLoadUrl) : queryData.video_url || queryData.url; // 从响应中获取视频URL + + videoRes = await axios.get(finalDownloadUrl, { + headers: isThreeUrlMode ? { Authorization: authorization } : {}, + responseType: "arraybuffer", + timeout: 60 * 1000 * 10, // 60秒超时 + }); + break; // 成功则跳出循环 + } catch (error) { + lastError = error; + console.error(`视频下载失败,第 ${i + 1}/${retries} 次尝试:`, error); + if (i < retries - 1) { + // 等待后重试,使用指数退避 + await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1000)); + } + } + } + + if (!videoRes) { + throw new Error(`视频下载失败,已重试 ${retries} 次: ${lastError}`); + } + + // 将视频buffer转换为base64或直接返回buffer + const savePath = input.savePath.endsWith(".mp4") ? input.savePath : path.join(input.savePath, `other_${Date.now()}.mp4`); + await u.oss.writeFile(input.savePath, videoRes.data); + + return { completed: true, url: savePath }; + } + if (queryData.status === "failed") return { completed: false, error: `任务失败: ${queryData.error || "未知错误"}` }; + // if (queryData.status === "QUEUED" || queryData.status === "RUNNING") return { completed: false }; + return { completed: false }; + }); +}; diff --git a/src/utils/ai/video/owned/other.ts b/src/utils/ai/video/owned/other.ts index 226a21b..8814106 100644 --- a/src/utils/ai/video/owned/other.ts +++ b/src/utils/ai/video/owned/other.ts @@ -71,15 +71,29 @@ export default async (input: VideoConfig, config: AIConfig) => { const requestBody: any = { model: config.model, - prompt: input.prompt, - aspect_ratio: input.aspectRatio || "16:9", - size: "720p", + images: [ + "https://prod-ss-images.s3.cn-northwest-1.amazonaws.com.cn/vidu-maas/template/startend2video-1.jpeg", + "https://prod-ss-images.s3.cn-northwest-1.amazonaws.com.cn/vidu-maas/template/startend2video-2.jpeg", + ], + prompt: + "The camera zooms in on the bird, which then flies to the right. With its flight being smooth and natural, the bird soars in the sky. with a red light effect following and surrounding it from behind.", + duration: 5, + seed: 0, + resolution: "1080p", + audio: true, + off_peak: false, }; if (input.imageBase64 && input.imageBase64.length) { - requestBody.images = input.imageBase64; + requestBody.images = [ + "https://prod-ss-images.s3.cn-northwest-1.amazonaws.com.cn/vidu-maas/template/startend2video-1.jpeg", + "https://prod-ss-images.s3.cn-northwest-1.amazonaws.com.cn/vidu-maas/template/startend2video-2.jpeg", + ]; } + console.log("%c Line:86 🍷 requestUrl", "background:#4fff4B", requestUrl); + console.log("%c Line:89 🥪 authorization", "background:#3f7cff", authorization); + const response = await axios.post(requestUrl, JSON.stringify(requestBody), { headers: { Authorization: authorization, @@ -88,6 +102,7 @@ export default async (input: VideoConfig, config: AIConfig) => { }); taskId = response.data.id; resData = response.data; + console.log("%c Line:91 🍎 resData", "background:#ed9ec7", resData); } console.log("%c Line:87 🥒 taskId", "background:#f5ce50", taskId); @@ -95,7 +110,7 @@ export default async (input: VideoConfig, config: AIConfig) => { return await pollTask(async () => { // 构建查询URL,两个地址模式时使用URL参数 - const finalQueryUrl = isThreeUrlMode ? template({ id: taskId }, queryUrl) : `${queryUrl}?id=${taskId}`; + const finalQueryUrl = isThreeUrlMode ? template({ id: taskId }, queryUrl) : template({ id: taskId }, queryUrl); const { data: queryData } = await axios.get(finalQueryUrl, { headers: { Authorization: authorization }, @@ -111,7 +126,8 @@ export default async (input: VideoConfig, config: AIConfig) => { for (let i = 0; i < retries; i++) { try { // 构建下载URL - const finalDownloadUrl = isThreeUrlMode && downLoadUrl ? template({ id: taskId }, downLoadUrl) : queryData.video_url || queryData.url; // 从响应中获取视频URL + const finalDownloadUrl = + isThreeUrlMode && downLoadUrl ? template({ id: taskId }, downLoadUrl) : queryData.video_url || queryData.url || queryData.metadata.url; // 从响应中获取视频URL videoRes = await axios.get(finalDownloadUrl, { headers: isThreeUrlMode ? { Authorization: authorization } : {}, diff --git a/src/utils/ai/video/owned/volcengine.ts b/src/utils/ai/video/owned/volcengine.ts index d657a02..12769ea 100644 --- a/src/utils/ai/video/owned/volcengine.ts +++ b/src/utils/ai/video/owned/volcengine.ts @@ -19,7 +19,7 @@ export default async (input: VideoConfig, config: AIConfig) => { type: "image_url", image_url: { url: base64 }, }; - if (isStartEndMode) { + if (isStartEndMode) { item.role = index === 0 ? "first_frame" : "last_frame"; } return item; @@ -46,7 +46,7 @@ export default async (input: VideoConfig, config: AIConfig) => { Authorization: authorization, }, }); - console.log("%c Line:44 🍡 createResponse", "background:#2eafb0", createResponse); + console.log("%c Line:44 🍡 createResponse", "background:#2eafb0", createResponse); const taskId = createResponse.data.id; @@ -54,14 +54,16 @@ export default async (input: VideoConfig, config: AIConfig) => { // 轮询任务状态 return await pollTask(async () => { - const data = await axios.get(`${baseUrl}/${taskId}`, { + const data = await axios.get(`${baseUrl}/query/${taskId}`, { headers: { Authorization: authorization }, }); + console.log("%c Line:62 🥕 data.data", "background:#e41a6a", data.data); const { status, content, error } = data.data; switch (status) { case "succeeded": + case "completed": return { completed: true, url: content?.video_url }; case "failed": case "cancelled": @@ -75,6 +77,9 @@ export default async (input: VideoConfig, config: AIConfig) => { return { completed: false, error: `任务${status}: ${errorMsg}` }; case "queued": case "running": + case "unknown": + case "submit": + case "in_progress": return { completed: false }; default: return { completed: false, error: `未知状态: ${status}` };