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,
apiKey: string,
input: GenerationInput
): Promise<GenerationOutput> {
): 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<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
// 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<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 {
@ -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<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 }],
};
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<string, unknown> = {
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<string, unknown>) };
@ -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<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
const pollResult = await pollKieTaskCompletion(requestId, apiKey, taskId);
let result: Record<string, unknown>;
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 {
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, 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;
}
}
@ -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<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
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

Loading…
Cancel
Save