diff --git a/src/app/api/generate/providers/kie.ts b/src/app/api/generate/providers/kie.ts index 754ef2b6..f85e13c0 100644 --- a/src/app/api/generate/providers/kie.ts +++ b/src/app/api/generate/providers/kie.ts @@ -554,50 +554,43 @@ export async function pollVeoTaskCompletion( } /** - * Generate image/video using Kie.ai API + * Submit a Kie.ai task (upload images, build params, create task). + * Returns the taskId and whether it's a Veo model. */ -export async function generateWithKie( +export async function submitKieTask( requestId: string, apiKey: string, input: GenerationInput -): Promise { +): Promise<{ taskId: string; isVeo: boolean }> { 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 = { ...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 + // GPT Image 1.5 does NOT support 'size' parameter 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(); 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 { @@ -605,7 +598,6 @@ export async function generateWithKie( } 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:')) { @@ -618,7 +610,6 @@ export async function generateWithKie( } } 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 { @@ -636,19 +627,16 @@ export async function generateWithKie( // 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 { @@ -664,14 +652,12 @@ export async function generateWithKie( 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; } @@ -699,87 +685,24 @@ export async function generateWithKie( // Keep original text } if (createResponse.status === 429) { - return { success: false, error: `${input.model.name}: Rate limit exceeded. Try again in a moment.` }; + throw new Error(`${input.model.name}: Rate limit exceeded. Try again in a moment.`); } - return { success: false, error: `${input.model.name}: ${errorDetail}` }; + throw new 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"}` }; + throw new 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" }; + throw new 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 | 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 }], - }; + return { taskId, isVeo: true }; } // ElevenLabs models use "text" instead of "prompt" @@ -791,8 +714,6 @@ export async function generateWithKie( } // All remaining Kie models use the standard createTask endpoint - // Strip capability suffixes from model IDs that use them internally - // e.g. "bytedance/seedance-2/text-to-video" → "bytedance/seedance-2" const apiModelId = getKieApiModelId(modelId); const requestBody: Record = { model: apiModelId, @@ -802,7 +723,6 @@ export async function generateWithKie( 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) }; @@ -813,7 +733,6 @@ export async function generateWithKie( } console.log(`[API:${requestId}] Request body:`, JSON.stringify(bodyForLogging, null, 2)); - // Create task const createResponse = await fetch(createUrl, { method: "POST", headers: { @@ -834,75 +753,219 @@ export async function generateWithKie( } if (createResponse.status === 429) { - return { - success: false, - error: `${input.model.name}: Rate limit exceeded. Try again in a moment.`, - }; + throw new Error(`${input.model.name}: Rate limit exceeded. Try again in a moment.`); } - return { - success: false, - error: `${input.model.name}: ${errorDetail}`, - }; + throw new 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}`, - }; + throw new 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", - }; + throw new Error("No task ID in response"); } console.log(`[API:${requestId}] Kie task created: ${taskId}`); + return { taskId, isVeo: false }; +} + +/** + * Check a Kie.ai task status once (no polling loop). + * Returns the current status and data if completed. + */ +export async function checkKieTaskOnce( + requestId: string, + apiKey: string, + taskId: string, + isVeo: boolean +): Promise<{ status: "processing" | "completed" | "failed"; data?: Record; error?: string; taskState?: string }> { + if (isVeo) { + const pollUrl = `https://api.kie.ai/api/v1/veo/record-info?taskId=${encodeURIComponent(taskId)}`; + + let response: Response; + try { + response = await fetch(pollUrl, { + headers: { "Authorization": `Bearer ${apiKey}` }, + }); + } catch (err) { + console.warn(`[API:${requestId}] Veo poll network error:`, err); + return { status: "processing", taskState: "NETWORK_ERROR" }; + } + + if (!response.ok) { + if (response.status === 404 || response.status === 422 || response.status === 429 || response.status >= 500) { + return { status: "processing", taskState: `HTTP_${response.status}` }; + } + return { status: "failed", error: `Failed to poll status: ${response.status}` }; + } - // Poll for completion - const pollResult = await pollKieTaskCompletion(requestId, apiKey, taskId); + let result: Record; + try { + result = await response.json(); + } catch { + return { status: "processing", taskState: "PARSE_ERROR" }; + } + + const successFlag = (result.data as Record | undefined)?.successFlag ?? -1; + console.log(`[API:${requestId}] Veo task successFlag: ${successFlag}`); + + if (successFlag === 1) { + return { status: "completed", data: result.data as Record }; + } + if (successFlag === 2 || successFlag === 3) { + const errorMessage = (result.data as Record)?.errorMessage || "Generation failed"; + return { status: "failed", error: errorMessage as string }; + } + return { status: "processing", taskState: `successFlag=${successFlag}` }; + } + + // Standard Kie polling + const pollUrl = `https://api.kie.ai/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`; + + let response: Response; + try { + response = await fetch(pollUrl, { + headers: { "Authorization": `Bearer ${apiKey}` }, + }); + } catch (err) { + console.warn(`[API:${requestId}] Kie poll network error:`, err); + return { status: "processing", taskState: "NETWORK_ERROR" }; + } - if (!pollResult.success) { + if (!response.ok) { + if (response.status === 404 || response.status === 422 || response.status === 429 || response.status >= 500) { + return { status: "processing", taskState: `HTTP_${response.status}` }; + } + return { status: "failed", error: `Failed to poll status: ${response.status}` }; + } + + let result: Record; + try { + result = await response.json(); + } catch { + return { status: "processing", taskState: "PARSE_ERROR" }; + } + + // Kie API can return HTTP 200 with code != 200 + if (result.code && result.code !== 200) { + console.log(`[API:${requestId}] Kie poll returned code ${result.code}: ${(result as Record).msg || ""}`); + return { status: "processing", taskState: `code=${result.code}` }; + } + + const data = result.data as Record | undefined; + const state = ((data?.state || result.state || result.status || "") as string).toUpperCase(); + + console.log(`[API:${requestId}] Kie task state: ${state}`); + + if (state === "SUCCESS" || state === "COMPLETED") { + return { status: "completed", data: 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 = data?.failMsg || data?.errorMessage || result.error || result.message || "Generation failed"; + return { status: "failed", error: errorMessage as string }; + } + + return { status: "processing", taskState: state }; +} + +/** Info needed to fetch the final media result from completed poll data */ +export interface KieMediaResultInput { + pollData: Record; + isVeo: boolean; + modelName: string; + capabilities: string[]; +} + +/** + * Fetch the final media result from completed Kie poll data. + * Extracts media URL, downloads, converts to base64. + */ +export async function fetchKieMediaResult( + requestId: string, + info: KieMediaResultInput +): Promise { + const { pollData: data, isVeo, modelName, capabilities } = info; + + if (isVeo) { + let mediaUrl: string | null = null; + const responseObj = data?.response as Record | 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" }; + } + + const mediaUrlCheck = validateMediaUrl(mediaUrl); + if (!mediaUrlCheck.valid) { + return { success: false, error: `Invalid media URL: ${mediaUrlCheck.error}` }; + } + + 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`); + + 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: false, - error: `${input.model.name}: ${pollResult.error}`, + success: true, + outputs: [{ type: "video", data: `data:${contentType};base64,${mediaBase64}`, url: mediaUrl }], }; } - // Extract output URL from result - // Kie API returns: { data: { status: "success", resultJson: { resultUrls: ["url1", "url2"] } } } - const data = pollResult.data; + // Standard Kie result extraction 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")); + const isAudioModel = 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 | 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; } catch { - // Not valid JSON, keep as-is resultJson = undefined; } } @@ -911,11 +974,8 @@ export async function generateWithKie( 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) { + } else if (data.videoUrl) { mediaUrl = data.videoUrl as string; isVideo = true; } else if (data.video_url) { @@ -924,9 +984,7 @@ export async function generateWithKie( } 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) { + } else if (data.imageUrl) { mediaUrl = data.imageUrl as string; } else if (data.image_url) { mediaUrl = data.image_url as string; @@ -941,40 +999,28 @@ export async function generateWithKie( if (!mediaUrl) { console.error(`[API:${requestId}] No media URL found in Kie response:`, data); - return { - success: false, - error: "No output URL in response", - }; + 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}`, - }; + 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` }; @@ -982,7 +1028,6 @@ export async function generateWithKie( 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; @@ -998,12 +1043,10 @@ export async function generateWithKie( 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); + const mediaSizeMB = mediaArrayBuffer.byteLength / (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`); @@ -1017,18 +1060,11 @@ export async function generateWithKie( }; } - // 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, - }, - ], + outputs: [{ type: "video", data: "", url: mediaUrl }], }; } @@ -1046,3 +1082,41 @@ export async function generateWithKie( ], }; } + +/** + * Generate image/video using Kie.ai API + * Composes submitKieTask + polling + fetchKieMediaResult for backward compatibility. + */ +export async function generateWithKie( + requestId: string, + apiKey: string, + input: GenerationInput +): Promise { + try { + const { taskId, isVeo } = await submitKieTask(requestId, apiKey, input); + + // Poll for completion using existing polling functions + const pollResult = isVeo + ? await pollVeoTaskCompletion(requestId, apiKey, taskId) + : await pollKieTaskCompletion(requestId, apiKey, taskId); + + if (!pollResult.success) { + return { + success: false, + error: `${input.model.name}: ${pollResult.error}`, + }; + } + + return await fetchKieMediaResult(requestId, { + pollData: pollResult.data!, + isVeo, + modelName: input.model.name, + capabilities: input.model.capabilities, + }); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Generation failed", + }; + } +} diff --git a/src/types/api.ts b/src/types/api.ts index 2173c8a0..f011b45b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -30,6 +30,13 @@ export interface GenerateResponse { model3dUrl?: string; // For 3D models, return GLB URL directly contentType?: "image" | "video" | "3d" | "audio"; error?: string; + // Client-side polling fields (for long-running Kie tasks) + polling?: boolean; // true = task submitted, poll for completion + taskId?: string; // Kie task ID to poll + pollProvider?: string; // 'kie' — tells poll endpoint which provider + pollModelId?: string; // model ID for result handling + pollModelName?: string; // display name for error messages + pollMediaType?: string; // 'video' | 'image' | 'audio' — for result handling } // API Request/Response types for LLM Text Generation