Browse Source

refactor: extract submitKieTask, checkKieTaskOnce, fetchKieMediaResult from generateWithKie

Split the monolithic generateWithKie into three reusable lifecycle functions
for client-side polling support. generateWithKie now composes them internally
for backward compatibility. Added polling fields to GenerateResponse type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
d9f2746bab
  1. 386
      src/app/api/generate/providers/kie.ts
  2. 7
      src/types/api.ts

386
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, requestId: string,
apiKey: string, apiKey: string,
input: GenerationInput input: GenerationInput
): Promise<GenerationOutput> { ): Promise<{ taskId: string; isVeo: boolean }> {
const modelId = input.model.id; const modelId = input.model.id;
console.log(`[API:${requestId}] Kie.ai generation - Model: ${modelId}, Images: ${input.images?.length || 0}, Prompt: ${input.prompt.length} chars`); 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) // Build the input object (all parameters go inside "input" for Kie API)
// Start with model-specific required defaults
const modelDefaults = getKieModelDefaults(modelId); const modelDefaults = getKieModelDefaults(modelId);
const inputParams: Record<string, unknown> = { ...modelDefaults }; const inputParams: Record<string, unknown> = { ...modelDefaults };
// Add prompt
if (input.prompt) { if (input.prompt) {
inputParams.prompt = input.prompt; inputParams.prompt = input.prompt;
} }
// Add model parameters (user params override defaults)
if (input.parameters) { if (input.parameters) {
Object.assign(inputParams, input.parameters); Object.assign(inputParams, input.parameters);
} }
// GPT Image 1.5 does NOT support 'size' parameter - only 'aspect_ratio' // GPT Image 1.5 does NOT support 'size' parameter
// Remove any stale 'size' values from old workflow data
if (modelId.startsWith("gpt-image/1.5")) { if (modelId.startsWith("gpt-image/1.5")) {
delete inputParams.size; delete inputParams.size;
} }
// Handle dynamic inputs FIRST (from schema-mapped connections) - these take priority // 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>(); const handledImageKeys = new Set<string>();
if (input.dynamicInputs) { if (input.dynamicInputs) {
for (const [key, value] of Object.entries(input.dynamicInputs)) { for (const [key, value] of Object.entries(input.dynamicInputs)) {
if (value !== null && value !== undefined && value !== '') { 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:')) { if (typeof value === 'string' && value.startsWith('data:')) {
// Single data URL - upload it
const url = await uploadMediaToKie(requestId, apiKey, value); 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") { 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; inputParams[key] = url;
} else { } else {
@ -605,7 +598,6 @@ export async function generateWithKie(
} }
handledImageKeys.add(key); handledImageKeys.add(key);
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
// Array of values - check if they're data URLs that need uploading
const processedArray: string[] = []; const processedArray: string[] = [];
for (const item of value) { for (const item of value) {
if (typeof item === 'string' && item.startsWith('data:')) { if (typeof item === 'string' && item.startsWith('data:')) {
@ -618,7 +610,6 @@ export async function generateWithKie(
} }
} }
if (processedArray.length > 0) { 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") { 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]; inputParams[key] = processedArray[0];
} else { } else {
@ -636,19 +627,16 @@ export async function generateWithKie(
// Handle image inputs (fallback - only if dynamicInputs didn't already set the image key) // Handle image inputs (fallback - only if dynamicInputs didn't already set the image key)
const imageKey = getKieImageInputKey(modelId); const imageKey = getKieImageInputKey(modelId);
if (input.images && input.images.length > 0 && !handledImageKeys.has(imageKey)) { if (input.images && input.images.length > 0 && !handledImageKeys.has(imageKey)) {
// Upload images to get URLs (Kie requires URLs, not base64)
const imageUrls: string[] = []; const imageUrls: string[] = [];
for (const image of input.images) { for (const image of input.images) {
if (image.startsWith("http")) { if (image.startsWith("http")) {
imageUrls.push(image); imageUrls.push(image);
} else { } else {
// Upload base64 media
const url = await uploadMediaToKie(requestId, apiKey, image); const url = await uploadMediaToKie(requestId, apiKey, image);
imageUrls.push(url); 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") { if (imageKey === "image_url" || imageKey === "video_url" || imageKey === "first_frame_url" || imageKey === "last_frame_url" || imageKey === "first_clip_url") {
inputParams[imageKey] = imageUrls[0]; inputParams[imageKey] = imageUrls[0];
} else { } else {
@ -664,14 +652,12 @@ export async function generateWithKie(
aspect_ratio: inputParams.aspect_ratio || "16:9", aspect_ratio: inputParams.aspect_ratio || "16:9",
}; };
// Add image URLs if present (for image-to-video)
if (inputParams.imageUrls) { if (inputParams.imageUrls) {
veoBody.imageUrls = Array.isArray(inputParams.imageUrls) veoBody.imageUrls = Array.isArray(inputParams.imageUrls)
? inputParams.imageUrls ? inputParams.imageUrls
: [inputParams.imageUrls]; : [inputParams.imageUrls];
} }
// Add optional seed
if (inputParams.seeds !== undefined) { if (inputParams.seeds !== undefined) {
veoBody.seeds = inputParams.seeds; veoBody.seeds = inputParams.seeds;
} }
@ -699,87 +685,24 @@ export async function generateWithKie(
// Keep original text // Keep original text
} }
if (createResponse.status === 429) { 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(); const createResult = await createResponse.json();
if (createResult.code && createResult.code !== 200) { 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; const taskId = createResult.data?.taskId || createResult.taskId;
if (!taskId) { if (!taskId) {
console.error(`[API:${requestId}] No taskId in Veo response:`, createResult); 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}`); console.log(`[API:${requestId}] Veo task created: ${taskId}`);
return { taskId, isVeo: true };
// 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" // ElevenLabs models use "text" instead of "prompt"
@ -791,8 +714,6 @@ export async function generateWithKie(
} }
// All remaining Kie models use the standard createTask endpoint // 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 apiModelId = getKieApiModelId(modelId);
const requestBody: Record<string, unknown> = { const requestBody: Record<string, unknown> = {
model: apiModelId, model: apiModelId,
@ -802,7 +723,6 @@ export async function generateWithKie(
const createUrl = "https://api.kie.ai/api/v1/jobs/createTask"; const createUrl = "https://api.kie.ai/api/v1/jobs/createTask";
console.log(`[API:${requestId}] Calling Kie.ai API: ${createUrl}`); console.log(`[API:${requestId}] Calling Kie.ai API: ${createUrl}`);
// Log full request body for debugging (truncate very long prompts)
const bodyForLogging = { ...requestBody }; const bodyForLogging = { ...requestBody };
if (bodyForLogging.input && typeof bodyForLogging.input === 'object') { if (bodyForLogging.input && typeof bodyForLogging.input === 'object') {
const inputForLogging = { ...(bodyForLogging.input as Record<string, unknown>) }; const inputForLogging = { ...(bodyForLogging.input as Record<string, unknown>) };
@ -813,7 +733,6 @@ export async function generateWithKie(
} }
console.log(`[API:${requestId}] Request body:`, JSON.stringify(bodyForLogging, null, 2)); console.log(`[API:${requestId}] Request body:`, JSON.stringify(bodyForLogging, null, 2));
// Create task
const createResponse = await fetch(createUrl, { const createResponse = await fetch(createUrl, {
method: "POST", method: "POST",
headers: { headers: {
@ -834,75 +753,219 @@ export async function generateWithKie(
} }
if (createResponse.status === 429) { if (createResponse.status === 429) {
return { throw new Error(`${input.model.name}: Rate limit exceeded. Try again in a moment.`);
success: false,
error: `${input.model.name}: Rate limit exceeded. Try again in a moment.`,
};
} }
return { throw new Error(`${input.model.name}: ${errorDetail}`);
success: false,
error: `${input.model.name}: ${errorDetail}`,
};
} }
const createResult = await createResponse.json(); const createResult = await createResponse.json();
// Kie API returns HTTP 200 even on errors, check the response code
if (createResult.code && createResult.code !== 200) { if (createResult.code && createResult.code !== 200) {
const errorMsg = createResult.msg || createResult.message || "API error"; const errorMsg = createResult.msg || createResult.message || "API error";
console.error(`[API:${requestId}] Kie API error (code ${createResult.code}):`, errorMsg); console.error(`[API:${requestId}] Kie API error (code ${createResult.code}):`, errorMsg);
return { throw new Error(`${input.model.name}: ${errorMsg}`);
success: false,
error: `${input.model.name}: ${errorMsg}`,
};
} }
const taskId = createResult.taskId || createResult.data?.taskId || createResult.id; const taskId = createResult.taskId || createResult.data?.taskId || createResult.id;
if (!taskId) { if (!taskId) {
console.error(`[API:${requestId}] No taskId in Kie response:`, createResult); console.error(`[API:${requestId}] No taskId in Kie response:`, createResult);
return { throw new Error("No task ID in response");
success: false,
error: "No task ID in response",
};
} }
console.log(`[API:${requestId}] Kie task created: ${taskId}`); 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<string, unknown>; 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 let result: Record<string, unknown>;
const pollResult = await pollKieTaskCompletion(requestId, apiKey, taskId); try {
result = await response.json();
} catch {
return { status: "processing", taskState: "PARSE_ERROR" };
}
const successFlag = (result.data as Record<string, unknown> | undefined)?.successFlag ?? -1;
console.log(`[API:${requestId}] Veo task successFlag: ${successFlag}`);
if (successFlag === 1) {
return { status: "completed", data: result.data as Record<string, unknown> };
}
if (successFlag === 2 || successFlag === 3) {
const errorMessage = (result.data as Record<string, unknown>)?.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<string, unknown>;
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<string, unknown>).msg || ""}`);
return { status: "processing", taskState: `code=${result.code}` };
}
const data = result.data as Record<string, unknown> | 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<string, unknown>;
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<GenerationOutput> {
const { pollData: data, isVeo, modelName, capabilities } = info;
if (isVeo) {
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" };
}
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 { return {
success: false, success: true,
error: `${input.model.name}: ${pollResult.error}`, outputs: [{ type: "video", data: `data:${contentType};base64,${mediaBase64}`, url: mediaUrl }],
}; };
} }
// Extract output URL from result // Standard Kie result extraction
// Kie API returns: { data: { status: "success", resultJson: { resultUrls: ["url1", "url2"] } } }
const data = pollResult.data;
let mediaUrl: string | null = null; let mediaUrl: string | null = null;
let isVideo = false; let isVideo = false;
let isAudio = false; let isAudio = false;
const isAudioModel = capabilities.some(c => c.includes("audio"));
// 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)); 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) { if (data) {
let resultJson = data.resultJson as Record<string, unknown> | string | undefined; 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') { if (typeof resultJson === 'string') {
try { try {
resultJson = JSON.parse(resultJson) as Record<string, unknown>; resultJson = JSON.parse(resultJson) as Record<string, unknown>;
} catch { } catch {
// Not valid JSON, keep as-is
resultJson = undefined; resultJson = undefined;
} }
} }
@ -911,11 +974,8 @@ export async function generateWithKie(
if (resultUrls && resultUrls.length > 0) { if (resultUrls && resultUrls.length > 0) {
mediaUrl = resultUrls[0]; mediaUrl = resultUrls[0];
// Check if it's a video based on URL
isVideo = mediaUrl.includes('.mp4') || mediaUrl.includes('.webm') || mediaUrl.includes('video'); isVideo = mediaUrl.includes('.mp4') || mediaUrl.includes('.webm') || mediaUrl.includes('video');
} } else if (data.videoUrl) {
// Fallback to other formats
else if (data.videoUrl) {
mediaUrl = data.videoUrl as string; mediaUrl = data.videoUrl as string;
isVideo = true; isVideo = true;
} else if (data.video_url) { } 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')) { } else if (data.output && typeof data.output === 'string' && (data.output as string).includes('.mp4')) {
mediaUrl = data.output as string; mediaUrl = data.output as string;
isVideo = true; isVideo = true;
} } else if (data.imageUrl) {
// Image outputs
else if (data.imageUrl) {
mediaUrl = data.imageUrl as string; mediaUrl = data.imageUrl as string;
} else if (data.image_url) { } else if (data.image_url) {
mediaUrl = data.image_url as string; mediaUrl = data.image_url as string;
@ -941,40 +999,28 @@ export async function generateWithKie(
if (!mediaUrl) { if (!mediaUrl) {
console.error(`[API:${requestId}] No media URL found in Kie response:`, data); console.error(`[API:${requestId}] No media URL found in Kie response:`, data);
return { return { success: false, error: "No output URL in response" };
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'))) { if (!isVideo && !isAudio && (mediaUrl.includes('.mp4') || mediaUrl.includes('.webm') || mediaUrl.includes('video'))) {
isVideo = true; 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'))) { if (!isVideo && !isAudio && (mediaUrl.includes('.mp3') || mediaUrl.includes('.wav') || mediaUrl.includes('.ogg') || mediaUrl.includes('.flac'))) {
isAudio = true; isAudio = true;
} }
// Validate URL before fetching
const mediaUrlCheck = validateMediaUrl(mediaUrl); const mediaUrlCheck = validateMediaUrl(mediaUrl);
if (!mediaUrlCheck.valid) { if (!mediaUrlCheck.valid) {
return { success: false, error: `Invalid media URL: ${mediaUrlCheck.error}` }; 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)}...`); console.log(`[API:${requestId}] Fetching output from: ${mediaUrl.substring(0, 80)}...`);
const mediaResponse = await fetch(mediaUrl); const mediaResponse = await fetch(mediaUrl);
if (!mediaResponse.ok) { if (!mediaResponse.ok) {
return { return { success: false, error: `Failed to fetch output: ${mediaResponse.status}` };
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); const mediaContentLength = parseInt(mediaResponse.headers.get("content-length") || "0", 10);
if (mediaContentLength > MAX_MEDIA_SIZE) { if (mediaContentLength > MAX_MEDIA_SIZE) {
return { success: false, error: `Media too large: ${(mediaContentLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` }; 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 rawContentType = mediaResponse.headers.get("content-type") || "";
const isConcreteMedia = rawContentType.startsWith("audio/") || rawContentType.startsWith("video/") || rawContentType.startsWith("image/"); 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/")) { if (rawContentType.startsWith("video/")) {
isVideo = true; isVideo = true;
isAudio = false; isAudio = false;
@ -998,12 +1043,10 @@ export async function generateWithKie(
if (mediaArrayBuffer.byteLength > MAX_MEDIA_SIZE) { if (mediaArrayBuffer.byteLength > MAX_MEDIA_SIZE) {
return { success: false, error: `Media too large: ${(mediaArrayBuffer.byteLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` }; return { success: false, error: `Media too large: ${(mediaArrayBuffer.byteLength / (1024 * 1024)).toFixed(0)}MB > 500MB limit` };
} }
const mediaSizeBytes = mediaArrayBuffer.byteLength; const mediaSizeMB = mediaArrayBuffer.byteLength / (1024 * 1024);
const mediaSizeMB = mediaSizeBytes / (1024 * 1024);
console.log(`[API:${requestId}] Output: ${contentType}, ${mediaSizeMB.toFixed(2)}MB`); console.log(`[API:${requestId}] Output: ${contentType}, ${mediaSizeMB.toFixed(2)}MB`);
// For audio models, return base64 encoded audio
if (isAudio) { if (isAudio) {
const audioBase64 = Buffer.from(mediaArrayBuffer).toString("base64"); const audioBase64 = Buffer.from(mediaArrayBuffer).toString("base64");
console.log(`[API:${requestId}] SUCCESS - Returning audio`); 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) { if (isVideo && mediaSizeMB > 20) {
console.log(`[API:${requestId}] SUCCESS - Returning URL for large video`); console.log(`[API:${requestId}] SUCCESS - Returning URL for large video`);
return { return {
success: true, success: true,
outputs: [ outputs: [{ type: "video", data: "", url: mediaUrl }],
{
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<GenerationOutput> {
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",
};
}
}

7
src/types/api.ts

@ -30,6 +30,13 @@ export interface GenerateResponse {
model3dUrl?: string; // For 3D models, return GLB URL directly model3dUrl?: string; // For 3D models, return GLB URL directly
contentType?: "image" | "video" | "3d" | "audio"; contentType?: "image" | "video" | "3d" | "audio";
error?: string; 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 // API Request/Response types for LLM Text Generation

Loading…
Cancel
Save