diff --git a/src/routes/other/testImage.ts b/src/routes/other/testImage.ts index 1fc3ef1..4d42b2b 100644 --- a/src/routes/other/testImage.ts +++ b/src/routes/other/testImage.ts @@ -16,14 +16,17 @@ export default router.post( }), async (req, res) => { const { modelName, apiKey, baseURL, manufacturer } = req.body; - - const image =await u.ai.image({ - prompt: "2D cat", - imageBase64: [], - aspectRatio: "16:9", - size: "1K", - }); - res.status(200).send(success(image)); + try { + const image = await u.ai.image({ + prompt: "生成16:9 四宫格图片,第一宫格是一只猫,第二宫格是一只狗, 第三宫格是一只老虎,第四宫格是猪。保证宫格图片标准等分", + imageBase64: [], + aspectRatio: "16:9", + size: "1K", + }); + res.status(200).send(success(image)); + } catch (e: any) { + return res.status(500).send(error(e?.response?.data ?? e?.message ?? "生成失败")); + } // try { // const contentStr = await u.ai.generateImage( diff --git a/src/utils/ai/generateImage.ts b/src/utils/ai/generateImage.ts deleted file mode 100644 index 16eb989..0000000 --- a/src/utils/ai/generateImage.ts +++ /dev/null @@ -1,201 +0,0 @@ -import axios from "axios"; -import u from "@/utils"; -import FormData from "form-data"; -import axiosRetry from "axios-retry"; -import sharp from "sharp"; - -interface ImageConfig { - systemPrompt?: string; - prompt: string; - imageBase64: string[]; - size: "1K" | "2K" | "4K"; - aspectRatio: string; - resType?: "url" | "b64"; -} - -interface ImageModelConfig { - model?: string; - apiKey?: string; - baseURL?: string; - manufacturer?: "openAi" | "gemini" | "volcengine" | "runninghub" | "apimart"; -} -// 上传 base64 图片到 runninghub -const uploadBase64ToRunninghub = async (base64Image: string, apiKey: string, baseURL: string): Promise => { - 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 urlToBase64 = async (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}`; -}; -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 => { - 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} 次`); -}; - -const generators = { - volcengine: async (config: ImageConfig, apiKey: string, baseURL: string, model: string) => { - if (config.size == "1K") config.size = "2K"; - apiKey = apiKey.replace("Bearer ", ""); - const body: Record = { - model, - prompt: config.prompt, - size: config.size, - response_format: "url", - sequential_image_generation: "disabled", - stream: false, - watermark: false, - }; - // 图生图:存在图片时添加 image 字段 - if (config.imageBase64) { - body.image = config.imageBase64; - } - const res = await axios.post(`https://ark.cn-beijing.volces.com/api/v3/images/generations`, body, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); - return res.data.data[0].url; - }, - - gemini: async (config: ImageConfig, apiKey: string, baseURL: string, model: string) => { - apiKey = apiKey.replace("Bearer ", ""); - const messages = [ - ...(config.systemPrompt ? [{ role: "system", content: config.systemPrompt }] : []), - { role: "user", content: config.prompt }, - ...config.imageBase64.map((img) => ({ role: "user", content: { image: img } })), - ]; - const res = await axios.post( - `${baseURL}/chat/completions`, - { model, stream: false, messages, extra_body: { google: { image_config: { aspect_ratio: config.aspectRatio, image_size: config.size } } } }, - { headers: { Authorization: "Bearer " + apiKey } }, - ); - - return res.data.choices[0].message.content; - }, - - runninghub: async (config: ImageConfig, apiKey: string, baseURL: string) => { - apiKey = apiKey.replace("Bearer ", ""); - const imageUrls = await Promise.all(config.imageBase64.map((base64Image) => uploadBase64ToRunninghub(base64Image, apiKey, baseURL))); - - const endpoint = config.imageBase64.length === 0 ? "/openapi/v2/rhart-image-n-pro/text-to-image" : "/openapi/v2/rhart-image-n-pro/edit"; - const taskRes = await axios.post( - `https://www.runninghub.cn${endpoint}`, - { prompt: config.prompt, resolution: config.size, aspectRatio: config.aspectRatio, ...(imageUrls.length > 0 && { imageUrls }) }, - { headers: { Authorization: "Bearer " + apiKey } }, - ); - const taskId = taskRes.data.taskId; - if (!taskId) throw new Error(`任务创建失败,${JSON.stringify(taskRes.data)}`); - - return pollTask(async () => { - const res = await axios.post(`https://www.runninghub.cn/task/openapi/outputs`, { taskId, apiKey: apiKey }); - const { code, msg, data } = res.data; - if (code === 0 && msg === "success") return { completed: true, imageUrl: data?.[0]?.fileUrl }; - if (code === 804 || code === 813) return { completed: false }; - if (code === 805) return { completed: false, error: `任务失败: ${data?.[0]?.failedReason?.exception_message || "未知原因"}` }; - return { completed: false, error: `未知状态: code=${code}, msg=${msg}` }; - }); - }, - - apimart: async (config: ImageConfig, apiKey: string, baseURL: string, model: string) => { - apiKey = apiKey.replace("Bearer ", ""); - const taskRes = await axios.post( - `https://api.apimart.ai/v1/images/generations`, - { model: "gemini-3-pro-image-preview", prompt: config.prompt, size: config.aspectRatio, n: 1, resolution: config.size }, - { headers: { Authorization: apiKey } }, - ); - - if (taskRes.data.code !== 200 || !taskRes.data.data?.[0]?.task_id) throw new Error("任务创建失败: " + JSON.stringify(taskRes.data)); - - const taskId = taskRes.data.data[0].task_id; - return pollTask(async () => { - const res = await axios.get(`https://api.apimart.ai/v1/tasks/${taskId}`, { headers: { Authorization: apiKey }, params: { language: "en" } }); - if (res.data.code !== 200) return { completed: false, error: `查询失败: ${JSON.stringify(res.data)}` }; - const { status, result } = res.data.data; - if (status === "completed") return { completed: true, imageUrl: result?.images?.[0]?.url?.[0] }; - if (status === "failed" || status === "cancelled") return { completed: false, error: `任务${status}` }; - return { completed: false }; - }); - }, -}; -export default async (config: ImageConfig, replaceConfig?: ImageModelConfig) => { - let { model, apiKey, baseURL, manufacturer } = await u.getConfig("image"); - if (replaceConfig) { - model = replaceConfig.model || model; - apiKey = replaceConfig.apiKey || apiKey; - baseURL = replaceConfig.baseURL || baseURL; - manufacturer = replaceConfig.manufacturer || manufacturer; - } - const generator = generators[manufacturer as keyof typeof generators]; - if (!generator) throw new Error(`不支持的厂商: ${manufacturer}`); - - let imageUrl = await generator(config, apiKey ?? "", baseURL ?? "", model ?? ""); - if (!config.resType) config.resType = "b64"; - if (config.resType === "b64" && imageUrl.startsWith("http")) imageUrl = await urlToBase64(imageUrl); - return imageUrl; -}; diff --git a/src/utils/ai/image/index.ts b/src/utils/ai/image/index.ts index 8aefebe..4eac7ca 100644 --- a/src/utils/ai/image/index.ts +++ b/src/utils/ai/image/index.ts @@ -5,8 +5,9 @@ import axios from "axios"; import volcengine from "./owned/volcengine"; import kling from "./owned/kling"; - - +import gemini from "./owned/gemini"; +import vidu from "./owned/vidu"; +import runninghub from "./owned/runninghub"; interface AIConfig { model?: string; apiKey?: string; @@ -21,11 +22,11 @@ const urlToBase64 = async (url: string): Promise => { }; const modelInstance = { - gemini: null, + gemini: gemini, volcengine: volcengine, kling: kling, - vidu: null, - runninghub: null, + vidu: vidu, + runninghub: runninghub, apimart: null, } as const; @@ -38,7 +39,8 @@ export default async (input: ImageConfig, config?: AIConfig) => { if (!owned) throw new Error("不支持的模型"); let imageUrl = await manufacturerFn(input, { model, apiKey, baseURL }); + console.log("%c Line:41 🍅 imageUrl", "background:#ed9ec7", imageUrl); if (!input.resType) input.resType = "b64"; if (input.resType === "b64" && imageUrl.startsWith("http")) imageUrl = await urlToBase64(imageUrl); - return input; + return imageUrl; }; diff --git a/src/utils/ai/image/modelList.ts b/src/utils/ai/image/modelList.ts index 2b9bae4..d5cad4b 100644 --- a/src/utils/ai/image/modelList.ts +++ b/src/utils/ai/image/modelList.ts @@ -33,18 +33,6 @@ const modelList: Owned[] = [ grid: true, type: "ti2i", }, - { - manufacturer: "gemini", - model: "gemini-2.5-flash-image-preview", - grid: true, - type: "ti2i", - }, - { - manufacturer: "gemini", - model: "gemini-2.5-flash-image-preview-all", - grid: true, - type: "ti2i", - }, { manufacturer: "gemini", model: "gemini-3-pro-image-preview", diff --git a/src/utils/ai/image/owned/gemini.ts b/src/utils/ai/image/owned/gemini.ts index e710879..f1c4123 100644 --- a/src/utils/ai/image/owned/gemini.ts +++ b/src/utils/ai/image/owned/gemini.ts @@ -1,8 +1,9 @@ import "../type"; import { createGoogleGenerativeAI } from "@ai-sdk/google"; -import { generateImage } from "ai"; +import { generateText } from "ai"; export default async (input: ImageConfig, config: AIConfig): Promise => { + console.log("%c Line:6 🌰 config", "background:#ffdd4d", config); if (!config.model) throw new Error("缺少Model名称"); if (!config.apiKey) throw new Error("缺少API Key"); if (!input.prompt) throw new Error("缺少提示词"); @@ -22,13 +23,30 @@ export default async (input: ImageConfig, config: AIConfig): Promise => "4K": "4096x4096", }; - const { image } = await generateImage({ - model: google.image(config.model), - prompt: fullPrompt, - aspectRatio: input.aspectRatio as "1:1" | "3:4" | "4:3" | "9:16" | "16:9", - size: sizeMap[input.size] ?? "1024x1024", + const result = await generateText({ + model: google.languageModel(config.model), + prompt: fullPrompt + `请直接输出图片`, + providerOptions: { + google: { + imageConfig: { + ...(config.model == "gemini-2.5-flash-image" + ? { aspectRatio: input.aspectRatio } + : { aspectRatio: input.aspectRatio, imageSize: input.size }), + }, + }, + }, }); + console.log(JSON.stringify(result.request, null, 2)); + console.log(JSON.stringify(result.response.body, null, 2)); + if (!result.files.length) { + console.error(JSON.stringify(result.response, null, 2)); + throw new Error("图片生成失败"); + } + let imageBase64; + for (const item of result.files) { + imageBase64 = `data:${item.mediaType};base64,${item.base64}`; + } // 返回生成的图片 base64 - return image.base64; + return imageBase64!; }; diff --git a/src/utils/ai/image/owned/runninghub.ts b/src/utils/ai/image/owned/runninghub.ts new file mode 100644 index 0000000..d2bb758 --- /dev/null +++ b/src/utils/ai/image/owned/runninghub.ts @@ -0,0 +1,93 @@ +import axios from "axios"; +import u from "@/utils"; +import FormData from "form-data"; +import axiosRetry from "axios-retry"; +import { OpenAIChatModel, type OpenAIChatModelOptions } from "@aigne/openai"; +import sharp from "sharp"; +import { pollTask } from "@/utils/ai/utils"; + +axiosRetry(axios, { retries: 3, retryDelay: () => 200 }); +// 上传 base64 图片到 runninghub +const uploadBase64ToRunninghub = async (base64Image: string, apiKey: string, baseURL: string): Promise => { + 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; + } +}; + +export default async (input: ImageConfig, config: AIConfig): Promise => { + if (!config.apiKey) throw new Error("缺少API Key"); + const apiKey = config.apiKey.replace("Bearer ", ""); + const baseURL = "https://www.runninghub.cn"; + const imageUrls = await Promise.all(input.imageBase64.map((base64Image) => uploadBase64ToRunninghub(base64Image, apiKey, baseURL))); + + const endpoint = input.imageBase64.length === 0 ? "/openapi/v2/rhart-image-n-pro/text-to-image" : "/openapi/v2/rhart-image-n-pro/edit"; + const taskRes = await axios.post( + `https://www.runninghub.cn${endpoint}`, + { prompt: input.prompt, resolution: input.size, aspectRatio: input.aspectRatio, ...(imageUrls.length > 0 && { imageUrls }) }, + { headers: { Authorization: "Bearer " + apiKey } }, + ); + const taskId = taskRes.data.taskId; + if (!taskId) throw new Error(`任务创建失败,${JSON.stringify(taskRes.data)}`); + + return pollTask(async () => { + const res = await axios.post(`https://www.runninghub.cn/task/openapi/outputs`, { taskId, apiKey: apiKey }); + const { code, msg, data } = res.data; + if (code === 0 && msg === "success") return { completed: true, imageUrl: data?.[0]?.fileUrl }; + if (code === 804 || code === 813) return { completed: false }; + if (code === 805) return { completed: false, error: `任务失败: ${data?.[0]?.failedReason?.exception_message || "未知原因"}` }; + return { completed: false, error: `未知状态: code=${code}, msg=${msg}` }; + }); +}; diff --git a/src/utils/ai/image/owned/vidu.ts b/src/utils/ai/image/owned/vidu.ts new file mode 100644 index 0000000..9405117 --- /dev/null +++ b/src/utils/ai/image/owned/vidu.ts @@ -0,0 +1,94 @@ +import "../type"; +import axios from "axios"; +import u from "@/utils"; +import { pollTask } from "@/utils/ai/utils"; +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"); +} +function template(replaceObj: Record, url: string) { + return url.replace(/\{(\w+)\}/g, (match, varName) => { + return replaceObj.hasOwnProperty(varName) ? replaceObj[varName] : match; + }); +} +export default async (input: ImageConfig, config: AIConfig): Promise => { + if (!config.model) throw new Error("缺少Model名称"); + if (!config.apiKey) throw new Error("缺少API Key"); + + const apiKey = "Token " + config.apiKey.replace(/Bearer\s+/g, "").trim(); + const viduq2Ratio = ["16:9", "9:16", "1:1", "3:4", "4:3", "21:9", "2:3", "3:2"]; + const viduq1Ratio = ["16:9", "9:16", "1:1", "3:4", "4:3"]; + let images: string[] = []; + const baseImages = input.imageBase64; + // 如果图片总数大于7,合并第7张及以后的图片 + if (baseImages) { + if (baseImages.length > 7) { + // 前6张原图 + images = baseImages.slice(0, 6); + // 第7张及以后的图片进行合并 + const mergeImageList = baseImages.slice(6); // 注意此处使用slice,不会改变原数组 + const mergedImage = await u.imageTools.mergeImages(mergeImageList, "10mb"); + images.push(mergedImage); + } else { + // 不足7张,直接全部加入 + images = baseImages; + } + } + + let size = "1080p"; + if (config.model == "viduq1") { + if (!images.length) throw new Error(`viduq1 进行图片生成必须传入一张图片`); + if (!viduq1Ratio.includes(input.aspectRatio)) throw new Error("不支持的图片比例:" + input.aspectRatio); + size = "1080p"; + } else { + if (input.size == "1K") size = "1080p"; + else size = input.size; + if (!viduq2Ratio.includes(input.aspectRatio)) throw new Error("不支持的图片比例:" + input.aspectRatio); + } + console.log("%c Line:23 🍔 size", "background:#ffdd4d", size); + + const body: Record = { + model: config.model, + prompt: input.prompt, + aspect_ratio: input.aspectRatio, + resolution: size, + ...(images.length && { images: images }), + }; + console.log("%c Line:27 🍷 body", "background:#6ec1c2", body); + const urlObj = getApiUrl(config.baseURL!); + try { + const { data } = await axios.post(urlObj.requestUrl, body, { headers: { Authorization: apiKey } }); + console.log("%c Line:35 🥕 data", "background:#93c0a4", data); + const queryUrl = template({ id: data.task_id }, urlObj.queryUrl); + console.log("%c Line:53 🍋 queryUrl", "background:#465975", queryUrl); + return await pollTask(async () => { + const { data: queryData } = await axios.get(queryUrl, { headers: { Authorization: apiKey } }); + console.log("%c Line:42 🍐 queryData", "background:#4fff4B", queryData); + + if (queryData.state !== 0) { + return { completed: false, error: queryData.message || "查询任务失败" }; + } + + const { state, err_code, creations } = queryData.data || {}; + + if (state === "failed") { + return { completed: false, error: err_code || "图片生成失败" }; + } + + if (state === "succeed") { + return { completed: true, imageUrl: creations?.[0]?.url }; + } + + return { completed: false }; + }); + } catch (error: any) { + const msg = u.error(error).message || "vidu 图片生成失败"; + throw new Error(msg); + } +};