Browse Source

fix: support video/audio uploads in Kie media handling

The dynamic input upload logic only matched data:image URLs, so video
data (data:video/mp4) was passed as raw base64 instead of being
uploaded to get a hosted URL. This caused "File type not supported"
errors for models like Kling 3.0 Motion Control that accept video.

- Rename uploadImageToKie → uploadMediaToKie (keep alias for compat)
- Rename detectImageType → detectMediaType with video magic bytes and
  fallback to declared MIME type for non-image content
- Broaden data URL checks from data:image to data: in generateWithKie
- Set uploadPath to videos/audio/images based on detected MIME type

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
d9f5d4b7e0
  1. 76
      src/app/api/generate/providers/kie.ts

76
src/app/api/generate/providers/kie.ts

@ -262,10 +262,10 @@ export function getKieImageInputKey(modelId: string): string {
/** /**
* Detect actual image type from binary data (magic bytes) * Detect media type from binary data (magic bytes), with fallback to declared MIME type
*/ */
export function detectImageType(buffer: Buffer): { mimeType: string; ext: string } { export function detectMediaType(buffer: Buffer, declaredMimeType?: string): { mimeType: string; ext: string } {
// Check magic bytes // Check image magic bytes
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) { if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
return { mimeType: "image/png", ext: "png" }; return { mimeType: "image/png", ext: "png" };
} }
@ -279,51 +279,72 @@ export function detectImageType(buffer: Buffer): { mimeType: string; ext: string
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
return { mimeType: "image/gif", ext: "gif" }; 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 // Default to PNG
return { mimeType: "image/png", ext: "png" }; return { mimeType: "image/png", ext: "png" };
} }
/** /**
* Upload a base64 image to Kie.ai and get a URL * Upload a base64 media file (image, video, or audio) to Kie.ai and get a URL
* Required for image-to-image models since Kie doesn't accept base64 directly * Required because Kie doesn't accept base64 directly needs hosted URLs
* Uses base64 upload endpoint (same as official Kie client) * Uses base64 upload endpoint (same as official Kie client)
*/ */
export async function uploadImageToKie( export async function uploadMediaToKie(
requestId: string, requestId: string,
apiKey: string, apiKey: string,
base64Image: string base64Media: string
): Promise<string> { ): Promise<string> {
// Extract mime type and data from data URL // Extract mime type and data from data URL
let declaredMimeType = "image/png"; let declaredMimeType = "image/png";
let imageData = base64Image; let mediaData = base64Media;
if (base64Image.startsWith("data:")) { if (base64Media.startsWith("data:")) {
const matches = base64Image.match(/^data:([^;]+);base64,(.+)$/); const matches = base64Media.match(/^data:([^;]+);base64,(.+)$/);
if (matches) { if (matches) {
declaredMimeType = matches[1]; declaredMimeType = matches[1];
imageData = matches[2]; mediaData = matches[2];
} }
} }
// Convert base64 to binary to detect actual type // Convert base64 to binary to detect actual type
const binaryData = Buffer.from(imageData, "base64"); const binaryData = Buffer.from(mediaData, "base64");
if (binaryData.length > MAX_UPLOAD_SIZE) { if (binaryData.length > MAX_UPLOAD_SIZE) {
throw new Error(`[API:${requestId}] Image too large to upload (${(binaryData.length / (1024 * 1024)).toFixed(1)}MB, max ${MAX_UPLOAD_SIZE / (1024 * 1024)}MB)`); 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 image type from magic bytes (don't trust the declared MIME type) // Detect actual media type from magic bytes, falling back to declared MIME type
const detected = detectImageType(binaryData); const detected = detectMediaType(binaryData, declaredMimeType);
const mimeType = detected.mimeType; const mimeType = detected.mimeType;
const ext = detected.ext; const ext = detected.ext;
const filename = `upload_${Date.now()}.${ext}`; const filename = `upload_${Date.now()}.${ext}`;
const uploadPath = mimeType.startsWith("video/") ? "videos" : mimeType.startsWith("audio/") ? "audio" : "images";
console.log(`[API:${requestId}] Uploading image to Kie.ai: ${filename} (${(binaryData.length / 1024).toFixed(1)}KB) [declared: ${declaredMimeType}, actual: ${mimeType}]`); 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) // Use base64 upload endpoint (same as official Kie client)
// Format: data:{mime_type};base64,{data} // Format: data:{mime_type};base64,{data}
const dataUrl = `data:${mimeType};base64,${imageData}`; const dataUrl = `data:${mimeType};base64,${mediaData}`;
const response = await fetch("https://kieai.redpandaai.co/api/file-base64-upload", { const response = await fetch("https://kieai.redpandaai.co/api/file-base64-upload", {
method: "POST", method: "POST",
@ -333,7 +354,7 @@ export async function uploadImageToKie(
}, },
body: JSON.stringify({ body: JSON.stringify({
base64Data: dataUrl, base64Data: dataUrl,
uploadPath: "images", uploadPath,
fileName: filename, fileName: filename,
}), }),
}); });
@ -359,10 +380,13 @@ export async function uploadImageToKie(
throw new Error(`No download URL in upload response. Response: ${JSON.stringify(result).substring(0, 200)}`); throw new Error(`No download URL in upload response. Response: ${JSON.stringify(result).substring(0, 200)}`);
} }
console.log(`[API:${requestId}] Image uploaded: ${downloadUrl.substring(0, 80)}...`); console.log(`[API:${requestId}] Media uploaded: ${downloadUrl.substring(0, 80)}...`);
return downloadUrl; return downloadUrl;
} }
/** @deprecated Use uploadMediaToKie instead */
export const uploadImageToKie = uploadMediaToKie;
/** /**
* Poll Kie.ai task status until completion * Poll Kie.ai task status until completion
*/ */
@ -512,10 +536,10 @@ export async function generateWithKie(
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 an image input that needs uploading // Check if this is a media input (image/video/audio) that needs uploading
if (typeof value === 'string' && value.startsWith('data:image')) { if (typeof value === 'string' && value.startsWith('data:')) {
// Single data URL - upload it // Single data URL - upload it
const url = await uploadImageToKie(requestId, apiKey, value); const url = await uploadMediaToKie(requestId, apiKey, value);
// Singular keys get a string, plural keys get an array // 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;
@ -527,8 +551,8 @@ export async function generateWithKie(
// Array of values - check if they're data URLs that need uploading // 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:image')) { if (typeof item === 'string' && item.startsWith('data:')) {
const url = await uploadImageToKie(requestId, apiKey, item); const url = await uploadMediaToKie(requestId, apiKey, item);
processedArray.push(url); processedArray.push(url);
} else if (typeof item === 'string' && item.startsWith('http')) { } else if (typeof item === 'string' && item.startsWith('http')) {
processedArray.push(item); processedArray.push(item);
@ -561,8 +585,8 @@ export async function generateWithKie(
if (image.startsWith("http")) { if (image.startsWith("http")) {
imageUrls.push(image); imageUrls.push(image);
} else { } else {
// Upload base64 image // Upload base64 media
const url = await uploadImageToKie(requestId, apiKey, image); const url = await uploadMediaToKie(requestId, apiKey, image);
imageUrls.push(url); imageUrls.push(url);
} }
} }

Loading…
Cancel
Save