You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

989 lines
33 KiB

/**
* Kie.ai Provider for Generate API Route
*
* Handles image/video generation using Kie.ai API.
* Supports standard createTask endpoint and Veo-specific endpoints.
*/
import { GenerationInput, GenerationOutput } from "@/lib/providers/types";
import { validateMediaUrl } from "@/utils/urlValidation";
const MAX_MEDIA_SIZE = 500 * 1024 * 1024; // 500MB
const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; // 20MB
/**
* Get default required parameters for a Kie model
* Many Kie models require specific parameters to be present even if not user-specified
*/
export function getKieModelDefaults(modelId: string): Record<string, unknown> {
switch (modelId) {
// GPT Image models
case "gpt-image/1.5-text-to-image":
case "gpt-image/1.5-image-to-image":
return {
aspect_ratio: "3:2",
quality: "medium",
};
// Z-Image model
case "z-image":
return {
aspect_ratio: "1:1",
};
// Seedream models
case "seedream/4.5-text-to-image":
case "seedream/4.5-edit":
return {
aspect_ratio: "1:1",
quality: "basic",
};
// Nano Banana 2 (Kie)
case "nano-banana-2":
return {
aspect_ratio: "auto",
resolution: "1K",
};
// Nano Banana Pro (Kie)
case "nano-banana-pro":
return {
aspect_ratio: "1:1",
resolution: "1K",
};
// Flux-2 models
case "flux-2/pro-text-to-image":
case "flux-2/pro-image-to-image":
case "flux-2/flex-text-to-image":
case "flux-2/flex-image-to-image":
return {
aspect_ratio: "1:1",
};
// Imagen 4 models
case "google/imagen4":
case "google/imagen4-ultra":
return {};
case "google/imagen4-fast":
return {
aspect_ratio: "16:9",
num_images: 1,
};
// Seedream 5.0 Lite models
case "seedream/5-lite-text-to-image":
case "seedream/5-lite-image-to-image":
return {
aspect_ratio: "1:1",
quality: "basic",
};
// Wan 2.7 Image
case "wan/2-7-image":
return {
resolution: "2K",
n: 4,
};
// Grok Imagine image models
case "grok-imagine/text-to-image":
return {
aspect_ratio: "1:1",
};
case "grok-imagine/image-to-image":
return {};
// Seedance 2.0 models
case "bytedance/seedance-2/text-to-video":
case "bytedance/seedance-2/image-to-video":
case "bytedance/seedance-2-fast/text-to-video":
case "bytedance/seedance-2-fast/image-to-video":
return {
aspect_ratio: "16:9",
resolution: "720p",
duration: 8,
generate_audio: true,
web_search: false,
};
// Grok Imagine video models
case "grok-imagine/text-to-video":
return {
aspect_ratio: "2:3",
duration: "6",
mode: "normal",
};
case "grok-imagine/image-to-video":
return {
aspect_ratio: "2:3",
duration: "6",
mode: "normal",
};
// Kling 3.0 video models
case "kling-3.0/video/text-to-video":
case "kling-3.0/video/image-to-video":
return {
aspect_ratio: "16:9",
duration: "5",
mode: "pro",
};
// Kling 3.0 motion control
case "kling-3.0/motion-control":
return {
mode: "pro",
character_orientation: "video",
background_source: "input_video",
};
// Kling 2.6 video models
case "kling-2.6/text-to-video":
case "kling-2.6/image-to-video":
return {
aspect_ratio: "16:9",
duration: "5",
sound: true,
};
// Kling 2.6 motion control
case "kling-2.6/motion-control":
return {
mode: "720p",
character_orientation: "video",
};
// Kling 2.5 turbo models
case "kling/v2-5-turbo-text-to-video-pro":
case "kling/v2-5-turbo-image-to-video-pro":
return {
aspect_ratio: "16:9",
duration: "5",
cfg_scale: 0.5,
};
// Wan video models
case "wan/2-6-text-to-video":
case "wan/2-6-image-to-video":
return {
duration: "5",
resolution: "1080p",
};
case "wan/2-6-video-to-video":
return {
duration: "5",
resolution: "1080p",
};
// Wan 2.7 video models
case "wan/2-7-text-to-video":
return {
duration: 5,
resolution: "1080p",
ratio: "16:9",
};
case "wan/2-7-image-to-video":
return {
duration: 5,
resolution: "1080p",
};
// Topaz video upscale
case "topaz/video-upscale":
return {
upscale_factor: "2",
};
// Veo 3 models
case "veo3/text-to-video":
case "veo3/image-to-video":
case "veo3-fast/text-to-video":
case "veo3-fast/image-to-video":
return {
aspect_ratio: "16:9",
};
// ElevenLabs TTS models
case "elevenlabs/turbo-v2.5":
case "elevenlabs/multilingual-v2":
case "elevenlabs/text-to-dialogue-v3":
return {
output_format: "mp3_44100_128",
};
// ElevenLabs Sound Effects
case "elevenlabs/sound-effect-v2":
return {
output_format: "mp3_44100_128",
prompt_influence: 0.3,
};
default:
return {};
}
}
/**
* Get the correct image input parameter name for a Kie model
*/
export function getKieImageInputKey(modelId: string): string {
// Model-specific parameter names
if (modelId === "nano-banana-2") return "image_input";
if (modelId === "nano-banana-pro") return "image_input";
if (modelId === "seedream/4.5-edit") return "image_urls";
if (modelId === "gpt-image/1.5-image-to-image") return "input_urls";
// Flux-2 I2I models use input_urls
if (modelId === "flux-2/pro-image-to-image" || modelId === "flux-2/flex-image-to-image") return "input_urls";
// Wan 2.7 Image uses input_urls
if (modelId === "wan/2-7-image") return "input_urls";
// Seedance I2V models use first_frame_url (singular)
if (modelId === "bytedance/seedance-2/image-to-video" || modelId === "bytedance/seedance-2-fast/image-to-video") return "first_frame_url";
// Kling 2.5 turbo I2V uses singular image_url
if (modelId === "kling/v2-5-turbo-image-to-video-pro") return "image_url";
// Kling 3.0 motion control uses input_urls
if (modelId === "kling-3.0/motion-control") return "input_urls";
// Kling 2.6 motion control uses input_urls
if (modelId === "kling-2.6/motion-control") return "input_urls";
// Wan 2.7 I2V uses first_frame_url (singular)
if (modelId === "wan/2-7-image-to-video") return "first_frame_url";
// Topaz video upscale uses video_url (singular)
if (modelId === "topaz/video-upscale") return "video_url";
// Veo 3 models use imageUrls
if (modelId.startsWith("veo3")) return "imageUrls";
// Default for most models
return "image_urls";
}
/**
* Detect media type from binary data (magic bytes), with fallback to declared MIME type
*/
export function detectMediaType(buffer: Buffer, declaredMimeType?: string): { mimeType: string; ext: string } {
// Check image magic bytes
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
return { mimeType: "image/png", ext: "png" };
}
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return { mimeType: "image/jpeg", ext: "jpg" };
}
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
return { mimeType: "image/webp", ext: "webp" };
}
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
return { mimeType: "image/gif", ext: "gif" };
}
// Check video magic bytes (MP4: "ftyp" at offset 4)
if (buffer.length > 7 && buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
return { mimeType: "video/mp4", ext: "mp4" };
}
// Check WebM magic bytes (starts with 0x1A 0x45 0xDF 0xA3 - EBML header)
if (buffer[0] === 0x1A && buffer[1] === 0x45 && buffer[2] === 0xDF && buffer[3] === 0xA3) {
return { mimeType: "video/webm", ext: "webm" };
}
// Fall back to declared MIME type if magic bytes didn't match
if (declaredMimeType && declaredMimeType !== "image/png") {
const extMap: Record<string, string> = {
"video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov",
"audio/mpeg": "mp3", "audio/wav": "wav", "audio/ogg": "ogg",
"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif",
};
const ext = extMap[declaredMimeType] || declaredMimeType.split("/")[1] || "bin";
return { mimeType: declaredMimeType, ext };
}
// Default to PNG
return { mimeType: "image/png", ext: "png" };
}
/**
* Upload a base64 media file (image, video, or audio) to Kie.ai and get a URL
* Required because Kie doesn't accept base64 directly — needs hosted URLs
* Uses base64 upload endpoint (same as official Kie client)
*/
export async function uploadMediaToKie(
requestId: string,
apiKey: string,
base64Media: string
): Promise<string> {
// Extract mime type and data from data URL
let declaredMimeType = "image/png";
let mediaData = base64Media;
if (base64Media.startsWith("data:")) {
const matches = base64Media.match(/^data:([^;]+);base64,(.+)$/);
if (matches) {
declaredMimeType = matches[1];
mediaData = matches[2];
}
}
// Convert base64 to binary to detect actual type
const binaryData = Buffer.from(mediaData, "base64");
if (binaryData.length > MAX_UPLOAD_SIZE) {
throw new Error(`[API:${requestId}] File too large to upload (${(binaryData.length / (1024 * 1024)).toFixed(1)}MB, max ${MAX_UPLOAD_SIZE / (1024 * 1024)}MB)`);
}
// Detect actual media type from magic bytes, falling back to declared MIME type
const detected = detectMediaType(binaryData, declaredMimeType);
const mimeType = detected.mimeType;
const ext = detected.ext;
const filename = `upload_${Date.now()}.${ext}`;
const uploadPath = mimeType.startsWith("video/") ? "videos" : mimeType.startsWith("audio/") ? "audio" : "images";
console.log(`[API:${requestId}] Uploading media to Kie.ai: ${filename} (${(binaryData.length / 1024).toFixed(1)}KB) [declared: ${declaredMimeType}, actual: ${mimeType}, path: ${uploadPath}]`);
// Use base64 upload endpoint (same as official Kie client)
// Format: data:{mime_type};base64,{data}
const dataUrl = `data:${mimeType};base64,${mediaData}`;
const response = await fetch("https://kieai.redpandaai.co/api/file-base64-upload", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
base64Data: dataUrl,
uploadPath,
fileName: filename,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload image: ${response.status} - ${errorText}`);
}
const result = await response.json();
console.log(`[API:${requestId}] Kie upload response:`, JSON.stringify(result).substring(0, 300));
// Check for error in response
if (result.code && result.code !== 200 && !result.success) {
throw new Error(`Upload failed: ${result.msg || 'Unknown error'}`);
}
// Response format: { success: true, code: 200, data: { downloadUrl: "...", fileName: "...", fileSize: 123 } }
const downloadUrl = result.data?.downloadUrl || result.downloadUrl || result.url;
if (!downloadUrl) {
console.error(`[API:${requestId}] Upload response has no URL:`, result);
throw new Error(`No download URL in upload response. Response: ${JSON.stringify(result).substring(0, 200)}`);
}
console.log(`[API:${requestId}] Media uploaded: ${downloadUrl.substring(0, 80)}...`);
return downloadUrl;
}
/** @deprecated Use uploadMediaToKie instead */
export const uploadImageToKie = uploadMediaToKie;
/**
* Poll Kie.ai task status until completion
*/
export async function pollKieTaskCompletion(
requestId: string,
apiKey: string,
taskId: string,
): Promise<{ success: boolean; data?: Record<string, unknown>; error?: string }> {
const maxWaitTime = 10 * 60 * 1000; // 10 minutes for video
const pollInterval = 2000; // 2 seconds
const startTime = Date.now();
let lastStatus = "";
const pollUrl = `https://api.kie.ai/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`;
while (true) {
if (Date.now() - startTime > maxWaitTime) {
return { success: false, error: "Generation timed out after 10 minutes" };
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
const response = await fetch(pollUrl, {
headers: {
"Authorization": `Bearer ${apiKey}`,
},
});
if (!response.ok) {
return { success: false, error: `Failed to poll status: ${response.status}` };
}
const result = await response.json();
// Kie API returns "state" in result.data.state (not "status")
const state = (result.data?.state || result.state || result.status || "").toUpperCase();
if (state !== lastStatus) {
console.log(`[API:${requestId}] Kie task state: ${state}`);
lastStatus = state;
}
if (state === "SUCCESS" || state === "COMPLETED") {
return { success: true, data: result.data || result };
}
if (state === "FAIL" || state === "FAILED" || state === "ERROR") {
console.error(`[API:${requestId}] Kie task failed. Full response:`, JSON.stringify(result).substring(0, 1000));
const errorMessage = result.data?.failMsg || result.data?.errorMessage || result.error || result.message || "Generation failed";
return { success: false, error: errorMessage };
}
// Continue polling for: WAITING, QUEUING, GENERATING, PROCESSING, etc.
}
}
export function isVeoModel(modelId: string): boolean {
return modelId.startsWith("veo3/") || modelId.startsWith("veo3-fast/");
}
export function getVeoApiModelId(modelId: string): string {
if (modelId.startsWith("veo3-fast/")) return "veo3_fast";
return "veo3";
}
export async function pollVeoTaskCompletion(
requestId: string,
apiKey: string,
taskId: string,
): Promise<{ success: boolean; data?: Record<string, unknown>; error?: string }> {
const maxWaitTime = 10 * 60 * 1000;
const pollInterval = 2000;
const startTime = Date.now();
let lastStatus = -1;
const pollUrl = `https://api.kie.ai/api/v1/veo/record-info?taskId=${encodeURIComponent(taskId)}`;
while (true) {
if (Date.now() - startTime > maxWaitTime) {
return { success: false, error: "Generation timed out after 10 minutes" };
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
const response = await fetch(pollUrl, {
headers: { "Authorization": `Bearer ${apiKey}` },
});
if (!response.ok) {
return { success: false, error: `Failed to poll status: ${response.status}` };
}
const result = await response.json();
const successFlag = result.data?.successFlag ?? -1;
if (successFlag !== lastStatus) {
console.log(`[API:${requestId}] Veo task successFlag: ${successFlag}`);
lastStatus = successFlag;
}
if (successFlag === 1) {
return { success: true, data: result.data };
}
if (successFlag === 2 || successFlag === 3) {
const errorMessage = result.data?.errorMessage || "Generation failed";
return { success: false, error: errorMessage };
}
// successFlag === 0 means still generating, continue polling
}
}
/**
* Generate image/video using Kie.ai API
*/
export async function generateWithKie(
requestId: string,
apiKey: string,
input: GenerationInput
): Promise<GenerationOutput> {
const modelId = input.model.id;
console.log(`[API:${requestId}] Kie.ai generation - Model: ${modelId}, Images: ${input.images?.length || 0}, Prompt: ${input.prompt.length} chars`);
// Build the input object (all parameters go inside "input" for Kie API)
// Start with model-specific required defaults
const modelDefaults = getKieModelDefaults(modelId);
const inputParams: Record<string, unknown> = { ...modelDefaults };
// Add prompt
if (input.prompt) {
inputParams.prompt = input.prompt;
}
// Add model parameters (user params override defaults)
if (input.parameters) {
Object.assign(inputParams, input.parameters);
}
// GPT Image 1.5 does NOT support 'size' parameter - only 'aspect_ratio'
// Remove any stale 'size' values from old workflow data
if (modelId.startsWith("gpt-image/1.5")) {
delete inputParams.size;
}
// Handle dynamic inputs FIRST (from schema-mapped connections) - these take priority
// Track which image keys dynamicInputs already handled to avoid double-uploads
const handledImageKeys = new Set<string>();
if (input.dynamicInputs) {
for (const [key, value] of Object.entries(input.dynamicInputs)) {
if (value !== null && value !== undefined && value !== '') {
// Check if this is a media input (image/video/audio) that needs uploading
if (typeof value === 'string' && value.startsWith('data:')) {
// Single data URL - upload it
const url = await uploadMediaToKie(requestId, apiKey, value);
// Singular keys get a string, plural keys get an array
if (key === "image_url" || key === "video_url" || key === "tail_image_url" || key === "first_frame_url" || key === "last_frame_url" || key === "first_clip_url") {
inputParams[key] = url;
} else {
inputParams[key] = [url];
}
handledImageKeys.add(key);
} else if (Array.isArray(value)) {
// Array of values - check if they're data URLs that need uploading
const processedArray: string[] = [];
for (const item of value) {
if (typeof item === 'string' && item.startsWith('data:')) {
const url = await uploadMediaToKie(requestId, apiKey, item);
processedArray.push(url);
} else if (typeof item === 'string' && item.startsWith('http')) {
processedArray.push(item);
} else if (typeof item === 'string') {
processedArray.push(item);
}
}
if (processedArray.length > 0) {
// Singular keys get first element, plural keys get full array
if (key === "image_url" || key === "video_url" || key === "tail_image_url" || key === "first_frame_url" || key === "last_frame_url" || key === "first_clip_url") {
inputParams[key] = processedArray[0];
} else {
inputParams[key] = processedArray;
}
handledImageKeys.add(key);
}
} else {
inputParams[key] = value;
}
}
}
}
// Handle image inputs (fallback - only if dynamicInputs didn't already set the image key)
const imageKey = getKieImageInputKey(modelId);
if (input.images && input.images.length > 0 && !handledImageKeys.has(imageKey)) {
// Upload images to get URLs (Kie requires URLs, not base64)
const imageUrls: string[] = [];
for (const image of input.images) {
if (image.startsWith("http")) {
imageUrls.push(image);
} else {
// Upload base64 media
const url = await uploadMediaToKie(requestId, apiKey, image);
imageUrls.push(url);
}
}
// Some models use singular string, others use arrays
if (imageKey === "image_url" || imageKey === "video_url" || imageKey === "first_frame_url" || imageKey === "last_frame_url" || imageKey === "first_clip_url") {
inputParams[imageKey] = imageUrls[0];
} else {
inputParams[imageKey] = imageUrls;
}
}
// Veo 3 models use a different API endpoint and request format
if (isVeoModel(modelId)) {
const veoBody: Record<string, unknown> = {
prompt: inputParams.prompt,
model: getVeoApiModelId(modelId),
aspect_ratio: inputParams.aspect_ratio || "16:9",
};
// Add image URLs if present (for image-to-video)
if (inputParams.imageUrls) {
veoBody.imageUrls = Array.isArray(inputParams.imageUrls)
? inputParams.imageUrls
: [inputParams.imageUrls];
}
// Add optional seed
if (inputParams.seeds !== undefined) {
veoBody.seeds = inputParams.seeds;
}
const veoUrl = "https://api.kie.ai/api/v1/veo/generate";
console.log(`[API:${requestId}] Calling Veo API: ${veoUrl}`);
console.log(`[API:${requestId}] Veo request body:`, JSON.stringify(veoBody, null, 2));
const createResponse = await fetch(veoUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(veoBody),
});
if (!createResponse.ok) {
const errorText = await createResponse.text();
let errorDetail = errorText;
try {
const errorJson = JSON.parse(errorText);
errorDetail = errorJson.message || errorJson.error || errorJson.detail || errorText;
} catch {
// Keep original text
}
if (createResponse.status === 429) {
return { success: false, error: `${input.model.name}: Rate limit exceeded. Try again in a moment.` };
}
return { success: false, error: `${input.model.name}: ${errorDetail}` };
}
const createResult = await createResponse.json();
if (createResult.code && createResult.code !== 200) {
return { success: false, error: `${input.model.name}: ${createResult.msg || "API error"}` };
}
const taskId = createResult.data?.taskId || createResult.taskId;
if (!taskId) {
console.error(`[API:${requestId}] No taskId in Veo response:`, createResult);
return { success: false, error: "No task ID in Veo response" };
}
console.log(`[API:${requestId}] Veo task created: ${taskId}`);
// Poll with Veo-specific polling
const pollResult = await pollVeoTaskCompletion(requestId, apiKey, taskId);
if (!pollResult.success) {
return { success: false, error: `${input.model.name}: ${pollResult.error}` };
}
// Extract video URL from Veo response format
const data = pollResult.data;
let mediaUrl: string | null = null;
const responseObj = data?.response as Record<string, unknown> | undefined;
const resultUrls = (responseObj?.resultUrls || data?.resultUrls) as string[] | undefined;
if (resultUrls && resultUrls.length > 0) {
mediaUrl = resultUrls[0];
}
if (!mediaUrl) {
console.error(`[API:${requestId}] No media URL found in Veo response:`, data);
return { success: false, error: "No output URL in Veo response" };
}
// Validate URL before fetching
const mediaUrlCheck = validateMediaUrl(mediaUrl);
if (!mediaUrlCheck.valid) {
return { success: false, error: `Invalid media URL: ${mediaUrlCheck.error}` };
}
// Fetch the video and convert to base64
console.log(`[API:${requestId}] Fetching Veo output from: ${mediaUrl.substring(0, 80)}...`);
const mediaResponse = await fetch(mediaUrl);
if (!mediaResponse.ok) {
return { success: false, error: `Failed to fetch output: ${mediaResponse.status}` };
}
const mediaContentLength = parseInt(mediaResponse.headers.get("content-length") || "0", 10);
if (mediaContentLength > MAX_MEDIA_SIZE) {
return { success: false, error: `Media too large: ${(mediaContentLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` };
}
const contentType = mediaResponse.headers.get("content-type") || "video/mp4";
const mediaArrayBuffer = await mediaResponse.arrayBuffer();
if (mediaArrayBuffer.byteLength > MAX_MEDIA_SIZE) {
return { success: false, error: `Media too large: ${(mediaArrayBuffer.byteLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` };
}
const mediaSizeMB = mediaArrayBuffer.byteLength / (1024 * 1024);
console.log(`[API:${requestId}] Veo output: ${contentType}, ${mediaSizeMB.toFixed(2)}MB`);
// For very large videos (>20MB), return URL only (data left empty for consumers)
if (mediaSizeMB > 20) {
console.log(`[API:${requestId}] SUCCESS - Returning URL for large Veo video`);
return {
success: true,
outputs: [{ type: "video", data: "", url: mediaUrl }],
};
}
const mediaBase64 = Buffer.from(mediaArrayBuffer).toString("base64");
console.log(`[API:${requestId}] SUCCESS - Returning Veo video`);
return {
success: true,
outputs: [{ type: "video", data: `data:${contentType};base64,${mediaBase64}`, url: mediaUrl }],
};
}
// ElevenLabs models use "text" instead of "prompt"
if (modelId.startsWith("elevenlabs/")) {
if (inputParams.prompt) {
inputParams.text = inputParams.prompt;
delete inputParams.prompt;
}
}
// All remaining Kie models use the standard createTask endpoint
const requestBody: Record<string, unknown> = {
model: modelId,
input: inputParams,
};
const createUrl = "https://api.kie.ai/api/v1/jobs/createTask";
console.log(`[API:${requestId}] Calling Kie.ai API: ${createUrl}`);
// Log full request body for debugging (truncate very long prompts)
const bodyForLogging = { ...requestBody };
if (bodyForLogging.input && typeof bodyForLogging.input === 'object') {
const inputForLogging = { ...(bodyForLogging.input as Record<string, unknown>) };
if (typeof inputForLogging.prompt === 'string' && (inputForLogging.prompt as string).length > 200) {
inputForLogging.prompt = (inputForLogging.prompt as string).substring(0, 200) + '...[truncated]';
}
bodyForLogging.input = inputForLogging;
}
console.log(`[API:${requestId}] Request body:`, JSON.stringify(bodyForLogging, null, 2));
// Create task
const createResponse = await fetch(createUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
if (!createResponse.ok) {
const errorText = await createResponse.text();
let errorDetail = errorText;
try {
const errorJson = JSON.parse(errorText);
errorDetail = errorJson.message || errorJson.error || errorJson.detail || errorText;
} catch {
// Keep original text
}
if (createResponse.status === 429) {
return {
success: false,
error: `${input.model.name}: Rate limit exceeded. Try again in a moment.`,
};
}
return {
success: false,
error: `${input.model.name}: ${errorDetail}`,
};
}
const createResult = await createResponse.json();
// Kie API returns HTTP 200 even on errors, check the response code
if (createResult.code && createResult.code !== 200) {
const errorMsg = createResult.msg || createResult.message || "API error";
console.error(`[API:${requestId}] Kie API error (code ${createResult.code}):`, errorMsg);
return {
success: false,
error: `${input.model.name}: ${errorMsg}`,
};
}
const taskId = createResult.taskId || createResult.data?.taskId || createResult.id;
if (!taskId) {
console.error(`[API:${requestId}] No taskId in Kie response:`, createResult);
return {
success: false,
error: "No task ID in response",
};
}
console.log(`[API:${requestId}] Kie task created: ${taskId}`);
// Poll for completion
const pollResult = await pollKieTaskCompletion(requestId, apiKey, taskId);
if (!pollResult.success) {
return {
success: false,
error: `${input.model.name}: ${pollResult.error}`,
};
}
// Extract output URL from result
// Kie API returns: { data: { status: "success", resultJson: { resultUrls: ["url1", "url2"] } } }
const data = pollResult.data;
let mediaUrl: string | null = null;
let isVideo = false;
let isAudio = false;
// Used as fallback when content-type is ambiguous
const isAudioModel = input.model.capabilities.some(c => c.includes("audio"));
console.log(`[API:${requestId}] Kie poll result data:`, JSON.stringify(data).substring(0, 500));
// Try various response formats - Kie uses resultJson.resultUrls
// Note: resultJson is often a JSON string that needs parsing
if (data) {
let resultJson = data.resultJson as Record<string, unknown> | string | undefined;
// Parse resultJson if it's a string (Kie API returns it as escaped JSON string)
if (typeof resultJson === 'string') {
try {
resultJson = JSON.parse(resultJson) as Record<string, unknown>;
} catch {
// Not valid JSON, keep as-is
resultJson = undefined;
}
}
const resultUrls = ((resultJson as Record<string, unknown> | undefined)?.resultUrls || data.resultUrls) as string[] | undefined;
if (resultUrls && resultUrls.length > 0) {
mediaUrl = resultUrls[0];
// Check if it's a video based on URL
isVideo = mediaUrl.includes('.mp4') || mediaUrl.includes('.webm') || mediaUrl.includes('video');
}
// Fallback to other formats
else if (data.videoUrl) {
mediaUrl = data.videoUrl as string;
isVideo = true;
} else if (data.video_url) {
mediaUrl = data.video_url as string;
isVideo = true;
} else if (data.output && typeof data.output === 'string' && (data.output as string).includes('.mp4')) {
mediaUrl = data.output as string;
isVideo = true;
}
// Image outputs
else if (data.imageUrl) {
mediaUrl = data.imageUrl as string;
} else if (data.image_url) {
mediaUrl = data.image_url as string;
} else if (data.output && typeof data.output === 'string') {
mediaUrl = data.output as string;
} else if (data.url) {
mediaUrl = data.url as string;
} else if (Array.isArray(data.images) && data.images.length > 0) {
mediaUrl = (data.images[0] as { url?: string })?.url || data.images[0] as string;
}
}
if (!mediaUrl) {
console.error(`[API:${requestId}] No media URL found in Kie response:`, data);
return {
success: false,
error: "No output URL in response",
};
}
// Detect video from URL if not already detected
if (!isVideo && !isAudio && (mediaUrl.includes('.mp4') || mediaUrl.includes('.webm') || mediaUrl.includes('video'))) {
isVideo = true;
}
// Detect audio from URL if not already detected
if (!isVideo && !isAudio && (mediaUrl.includes('.mp3') || mediaUrl.includes('.wav') || mediaUrl.includes('.ogg') || mediaUrl.includes('.flac'))) {
isAudio = true;
}
// Validate URL before fetching
const mediaUrlCheck = validateMediaUrl(mediaUrl);
if (!mediaUrlCheck.valid) {
return { success: false, error: `Invalid media URL: ${mediaUrlCheck.error}` };
}
// Fetch the media and convert to base64
console.log(`[API:${requestId}] Fetching output from: ${mediaUrl.substring(0, 80)}...`);
const mediaResponse = await fetch(mediaUrl);
if (!mediaResponse.ok) {
return {
success: false,
error: `Failed to fetch output: ${mediaResponse.status}`,
};
}
// Check file size before downloading body
const mediaContentLength = parseInt(mediaResponse.headers.get("content-length") || "0", 10);
if (mediaContentLength > MAX_MEDIA_SIZE) {
return { success: false, error: `Media too large: ${(mediaContentLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` };
}
const rawContentType = mediaResponse.headers.get("content-type") || "";
const isConcreteMedia = rawContentType.startsWith("audio/") || rawContentType.startsWith("video/") || rawContentType.startsWith("image/");
// Content-type wins over URL-based detection; reset opposite flag to avoid conflicts
if (rawContentType.startsWith("video/")) {
isVideo = true;
isAudio = false;
} else if (rawContentType.startsWith("audio/")) {
isAudio = true;
isVideo = false;
} else if (!isConcreteMedia && !isVideo && !isAudio && isAudioModel) {
isAudio = true;
}
const contentType = rawContentType || (isVideo ? "video/mp4" : isAudio ? "audio/mpeg" : "image/png");
const mediaArrayBuffer = await mediaResponse.arrayBuffer();
if (mediaArrayBuffer.byteLength > MAX_MEDIA_SIZE) {
return { success: false, error: `Media too large: ${(mediaArrayBuffer.byteLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` };
}
const mediaSizeBytes = mediaArrayBuffer.byteLength;
const mediaSizeMB = mediaSizeBytes / (1024 * 1024);
console.log(`[API:${requestId}] Output: ${contentType}, ${mediaSizeMB.toFixed(2)}MB`);
// For audio models, return base64 encoded audio
if (isAudio) {
const audioBase64 = Buffer.from(mediaArrayBuffer).toString("base64");
console.log(`[API:${requestId}] SUCCESS - Returning audio`);
return {
success: true,
outputs: [{
type: "audio",
data: `data:${contentType};base64,${audioBase64}`,
url: mediaUrl,
}],
};
}
// For very large videos (>20MB), return URL only (data left empty for consumers)
if (isVideo && mediaSizeMB > 20) {
console.log(`[API:${requestId}] SUCCESS - Returning URL for large video`);
return {
success: true,
outputs: [
{
type: "video",
data: "",
url: mediaUrl,
},
],
};
}
const mediaBase64 = Buffer.from(mediaArrayBuffer).toString("base64");
console.log(`[API:${requestId}] SUCCESS - Returning ${isVideo ? "video" : "image"}`);
return {
success: true,
outputs: [
{
type: isVideo ? "video" : "image",
data: `data:${contentType};base64,${mediaBase64}`,
url: mediaUrl,
},
],
};
}