import "server-only"; import sharp from "sharp"; import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models"; import { createAtlasCloudRequestBody, createAtlasCloudVideoRequestBody, type AtlasCloudImageParams, type AtlasCloudModelConfig, type AtlasCloudVideoModelConfig, } from "@/lib/ai/atlascloud-request"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { uploadFile } from "@/lib/storage/service"; import type { VideoGenerationOptions } from "@/types/ai"; interface AtlasCloudConfig { apiKey: string; baseURL?: string; } interface UploadedImage { thumbnail: string; original: string; } const POLL_INTERVAL_MS = 3000; const POLL_TIMEOUT_MS = 20 * 60 * 1000; const DOWNLOAD_TIMEOUT_MS = 120_000; const TRANSIENT_FAILURE_LIMIT = 8; function normalizeBaseUrl(baseURL?: string) { return String(baseURL || ATLASCLOUD_BASE_URL).replace(/\/+$/, ""); } function joinApiPath(baseURL: string | undefined, path: string) { const base = normalizeBaseUrl(baseURL); const cleanPath = path.startsWith("/") ? path : `/${path}`; if (base.endsWith("/api/v1") && cleanPath.startsWith("/api/v1/")) { return `${base}${cleanPath.slice("/api/v1".length)}`; } return `${base}${cleanPath}`; } function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function parseJsonResponse(response: Response) { return response.text().then((text) => { if (!text.trim()) return null; try { return JSON.parse(text); } catch { return text; } }); } function extractErrorMessage(payload: any, fallback = "Atlas Cloud 请求失败") { if (typeof payload === "string" && payload.trim()) return payload.trim(); const value = payload?.data?.error?.message || payload?.data?.error || payload?.error?.message || payload?.error || payload?.message || fallback; return typeof value === "string" && value.trim() ? value.trim() : fallback; } function unwrapPrediction(payload: any) { return payload?.data && typeof payload.data === "object" ? payload.data : payload; } function extractTaskId(payload: any) { const prediction = unwrapPrediction(payload); return String( prediction?.id || prediction?.request_id || prediction?.task_id || "" ).trim(); } function extractOutputUrls(payload: any) { const prediction = unwrapPrediction(payload); const candidates = [ prediction?.outputs, prediction?.output, prediction?.images, prediction?.video, prediction?.video_url, prediction?.videoUrl, prediction?.url, prediction?.urls?.outputs, ]; const urls: string[] = []; for (const candidate of candidates) { const values = Array.isArray(candidate) ? candidate : candidate ? [candidate] : []; for (const value of values) { const url = typeof value === "string" ? value : String( value?.url || value?.video_url || value?.videoUrl || value?.image_url || value?.file_url || value?.src || "" ); if (/^(?:https?:\/\/|data:(?:image|video)\/)/i.test(url) && !urls.includes(url)) { urls.push(url); } } } return urls; } function extractPromptAndMedia( messages: Array<{ role: string; content?: any; parts?: any[] }> ) { const message = messages.filter((item) => item.role === "user").pop(); if (!message) throw new Error("未找到用户消息"); const texts: string[] = []; const imageUrls: string[] = []; const videoUrls: string[] = []; const pushText = (value: unknown) => { if (typeof value === "string" && value.trim()) texts.push(value.trim()); }; const pushMedia = (value: unknown, mediaType?: unknown) => { const url = String( typeof value === "string" ? value : (value as any)?.url || (value as any)?.imageUrl || (value as any)?.fileUrl || (value as any)?.src || "" ).trim(); if (!url || !/^(?:https?:\/\/|data:)/i.test(url)) return; const normalizedMediaType = String(mediaType || "").toLowerCase(); const isVideo = normalizedMediaType.startsWith("video/") || url.startsWith("data:video/") || /\.(?:mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url); const target = isVideo ? videoUrls : imageUrls; if (!target.includes(url)) target.push(url); }; const visitPart = (part: any) => { if (!part) return; if (typeof part === "string") return pushText(part); if (part.type === "text" || typeof part.text === "string") pushText(part.text); if (part.type === "image_url") pushMedia(part.image_url, "image/jpeg"); if (part.type === "image") pushMedia(part.image || part.url, "image/jpeg"); if (part.type === "file") { pushMedia( part.url || part.file || part.data, part.mediaType || part.contentType || part.mimeType ); } if (part.inlineData?.data) { pushMedia( `data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`, part.inlineData.mimeType ); } }; if (typeof message.content === "string") pushText(message.content); else if (Array.isArray(message.content)) message.content.forEach(visitPart); if (Array.isArray(message.parts)) message.parts.forEach(visitPart); const prompt = texts.join("\n").trim(); if (!prompt) throw new Error("请输入图片生成或编辑要求"); return { prompt, imageUrls, videoUrls }; } function parseDataUrl(value: string) { const match = value.match(/^data:([^;,]+);base64,(.+)$/s); if (!match) throw new Error("Atlas Cloud 参考素材 Base64 格式无效"); return { mimeType: match[1] || "image/png", buffer: Buffer.from(match[2] || "", "base64"), }; } async function loadMediaSource(mediaUrl: string) { if (mediaUrl.startsWith("data:")) return parseDataUrl(mediaUrl); const response = await fetch(mediaUrl, { cache: "no-store", redirect: "follow", signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), headers: { Accept: "image/*,video/*,*/*;q=0.8", "User-Agent": "yedir-ai-atlascloud/1.0", }, }); if (!response.ok) { throw new Error(`Atlas Cloud 参考素材下载失败(HTTP ${response.status})`); } return { mimeType: response.headers.get("content-type")?.split(";")[0] || "image/png", buffer: Buffer.from(await response.arrayBuffer()), }; } function detectExtension(mimeType: string) { if (/jpe?g/i.test(mimeType)) return "jpg"; if (/webp/i.test(mimeType)) return "webp"; if (/gif/i.test(mimeType)) return "gif"; if (/quicktime/i.test(mimeType)) return "mov"; if (/webm/i.test(mimeType)) return "webm"; if (/mp4/i.test(mimeType)) return "mp4"; return "png"; } async function uploadReferenceMedia( baseURL: string | undefined, apiKey: string, mediaUrl: string ) { if (/^https:\/\//i.test(mediaUrl)) return mediaUrl; const source = await loadMediaSource(mediaUrl); const form = new FormData(); form.append( "file", new Blob([source.buffer], { type: source.mimeType }), `reference-${Date.now()}.${detectExtension(source.mimeType)}` ); const response = await fetch(joinApiPath(baseURL, "/api/v1/model/uploadMedia"), { method: "POST", headers: { Authorization: `Bearer ${apiKey}` }, body: form, signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), }); const payload = await parseJsonResponse(response); if (!response.ok) { throw new Error( `${extractErrorMessage(payload, "Atlas Cloud 参考素材上传失败")}(HTTP ${response.status})` ); } const data = payload?.data && typeof payload.data === "object" ? payload.data : payload; const url = String( data?.url || data?.file_url || data?.fileUrl || data?.media_url || data?.mediaUrl || "" ).trim(); if (!url) throw new Error("Atlas Cloud 参考素材上传成功但未返回文件地址"); return url; } async function submitTask(params: { apiKey: string; baseURL?: string; endpoint: string; body: Record; }) { const response = await fetch(joinApiPath(params.baseURL, params.endpoint), { method: "POST", headers: { Authorization: `Bearer ${params.apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(params.body), signal: AbortSignal.timeout(120_000), }); const payload = await parseJsonResponse(response); if (!response.ok) { throw new Error( `${extractErrorMessage(payload)}(HTTP ${response.status})` ); } const immediateOutputs = extractOutputUrls(payload); if (immediateOutputs.length > 0) return { outputs: immediateOutputs, taskId: "" }; const taskId = extractTaskId(payload); if (!taskId) throw new Error("Atlas Cloud 未返回任务 ID"); return { outputs: [] as string[], taskId }; } async function waitForTask(params: { apiKey: string; baseURL?: string; endpoint: string; taskId: string; }) { const startedAt = Date.now(); let transientFailures = 0; while (Date.now() - startedAt < POLL_TIMEOUT_MS) { await delay(POLL_INTERVAL_MS); try { const endpoint = params.endpoint.replace( /\{(?:request_id|id|task_id)\}/g, encodeURIComponent(params.taskId) ); const response = await fetch(joinApiPath(params.baseURL, endpoint), { cache: "no-store", headers: { Authorization: `Bearer ${params.apiKey}` }, signal: AbortSignal.timeout(60_000), }); const payload = await parseJsonResponse(response); if (!response.ok) { if (response.status >= 500 || response.status === 429) { throw new Error(`HTTP ${response.status}`); } throw new Error(extractErrorMessage(payload)); } transientFailures = 0; const prediction = unwrapPrediction(payload); const status = String(prediction?.status || "").toLowerCase(); const outputs = extractOutputUrls(payload); if (status === "completed" || status === "succeeded" || status === "success") { if (outputs.length === 0) { throw new Error("Atlas Cloud 任务完成但未返回图片"); } return outputs; } if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) { throw new Error(extractErrorMessage(payload, "Atlas Cloud 图片生成失败")); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!/^HTTP (?:429|5\d\d)$/.test(message) && !/fetch|network|abort/i.test(message)) { throw error; } transientFailures += 1; if (transientFailures >= TRANSIENT_FAILURE_LIMIT) throw error; } } throw new Error("Atlas Cloud 图片生成超时,任务可能仍在处理,请稍后重试"); } function toArrayBuffer(buffer: Buffer) { return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ) as ArrayBuffer; } async function uploadImageBuffer(buffer: Buffer, mimeType: string) { const extension = detectExtension(mimeType); const thumbnailBuffer = await sharp(buffer, { failOn: "none" }) .resize(400, 400, { fit: "inside", withoutEnlargement: true }) .jpeg({ quality: 82 }) .toBuffer() .catch(() => buffer); const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const [original, thumbnail] = await Promise.all([ uploadFile(`atlascloud-orig-${suffix}.${extension}`, toArrayBuffer(buffer)), uploadFile(`atlascloud-thumb-${suffix}.jpg`, toArrayBuffer(thumbnailBuffer)), ]); return { original: original.url, thumbnail: thumbnail.url }; } async function archiveOutputImage(imageUrl: string): Promise { if (imageUrl.startsWith("data:")) { const parsed = parseDataUrl(imageUrl); return uploadImageBuffer(parsed.buffer, parsed.mimeType); } const deferred = await createDeferredUploadedImage(imageUrl, { providerType: "atlascloud", }); if (deferred) return deferred; const response = await fetch(imageUrl, { cache: "no-store", redirect: "follow", signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), headers: { "User-Agent": "yedir-ai-atlascloud/1.0" }, }); if (!response.ok) { throw new Error(`Atlas Cloud 结果图片下载失败(HTTP ${response.status})`); } return uploadImageBuffer( Buffer.from(await response.arrayBuffer()), response.headers.get("content-type") || "image/png" ); } export async function executeAtlasCloudVideoGeneration(params: { apiKey: string; baseURL?: string; modelId: string; modelConfig: AtlasCloudVideoModelConfig; options: VideoGenerationOptions; }) { const runtime = params.modelConfig.atlasCloud; if (!runtime) { throw new Error("Atlas Cloud 视频模型能力配置缺失,请重新同步模型"); } const inputImages = [ ...(params.options.firstFrameImage ? [params.options.firstFrameImage] : []), ...(params.options.lastFrameImage ? [params.options.lastFrameImage] : []), ...(params.options.referenceImages || []), ]; const uniqueImages = Array.from(new Set(inputImages)).slice( 0, Math.max(0, Math.min(14, runtime.maxReferenceImages || 0)) ); const referenceImages = await Promise.all( uniqueImages.map((url) => uploadReferenceMedia(params.baseURL, params.apiKey, url)) ); const { body } = createAtlasCloudVideoRequestBody({ modelId: params.modelId, options: params.options, referenceImages, modelConfig: params.modelConfig, }); const submitted = await submitTask({ apiKey: params.apiKey, baseURL: params.baseURL, endpoint: runtime.submitEndpoint, body, }); const outputUrls = submitted.outputs.length ? submitted.outputs : await waitForTask({ apiKey: params.apiKey, baseURL: params.baseURL, endpoint: runtime.pollEndpoint, taskId: submitted.taskId, }); const videoUrl = outputUrls[0]; if (!videoUrl) { throw new Error("Atlas Cloud 视频任务完成但未返回视频地址"); } return { url: videoUrl, metadata: { provider: "atlascloud", taskId: submitted.taskId || null, temporaryVideoUrl: videoUrl, request: body, }, }; } function formatMarkdown(images: UploadedImage[], imageSize: string) { return images .map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`) .join("\n\n"); } export function createAtlasCloud({ apiKey, baseURL }: AtlasCloudConfig) { return { provider: "atlascloud", chat: (modelId: string, defaultModelConfig?: AtlasCloudModelConfig | null) => ({ provider: "atlascloud", doGenerate: async (options: { messages: Array<{ role: string; content?: any; parts?: any[] }>; imageParams?: AtlasCloudImageParams; imageGenConfig?: AtlasCloudModelConfig | null; overrideModelId?: string; }) => { const runtimeModelId = String(options.overrideModelId || modelId || "").trim(); if (!runtimeModelId) throw new Error("Atlas Cloud 模型 ID 未配置"); const modelConfig = options.imageGenConfig || defaultModelConfig || {}; if (!modelConfig.atlasCloud) { throw new Error("Atlas Cloud 模型能力配置缺失,请在系统设置中重新同步模型"); } const { prompt, imageUrls, videoUrls } = extractPromptAndMedia( options.messages ); const maxReferenceImages = Math.max( 0, Math.min(14, modelConfig.maxReferenceImages ?? modelConfig.atlasCloud.maxReferenceImages) ); const referenceImages = await Promise.all( imageUrls .slice(0, maxReferenceImages) .map((url) => uploadReferenceMedia(baseURL, apiKey, url)) ); const maxVideoReferences = Math.max( 0, Math.min( 4, modelConfig.maxVideoReferences ?? modelConfig.atlasCloud.maxVideoReferences ?? 0 ) ); const referenceVideos = await Promise.all( videoUrls .slice(0, maxVideoReferences) .map((url) => uploadReferenceMedia(baseURL, apiKey, url)) ); const { body, imageSize } = createAtlasCloudRequestBody({ modelId: runtimeModelId, prompt, referenceImages, referenceVideos, imageParams: options.imageParams, modelConfig, }); const submitted = await submitTask({ apiKey, baseURL, endpoint: modelConfig.atlasCloud.submitEndpoint, body, }); const outputUrls = submitted.outputs.length ? submitted.outputs : await waitForTask({ apiKey, baseURL, endpoint: modelConfig.atlasCloud.pollEndpoint, taskId: submitted.taskId, }); const requestedCount = Math.max(1, options.imageParams?.imageCount || 1); const archived = await Promise.all( outputUrls.slice(0, requestedCount).map(archiveOutputImage) ); const text = formatMarkdown(archived, imageSize); return { text, finishReason: "stop" as const, usage: { promptTokens: 0, completionTokens: text.length }, }; }, doStream: async function* (options: { messages: Array<{ role: string; content?: any; parts?: any[] }>; imageParams?: AtlasCloudImageParams; imageGenConfig?: AtlasCloudModelConfig | null; overrideModelId?: string; }) { const result = await this.doGenerate(options); yield { type: "text-delta", textDelta: result.text }; }, }), }; }