diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 2d62db43..6c5ff3bb 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -1357,10 +1357,32 @@ function isKieVeoModel(modelId: string): boolean { return modelId === "veo3" || modelId === "veo3_fast"; } +/** + * Detect actual image type from binary data (magic bytes) + */ +function detectImageType(buffer: Buffer): { mimeType: string; ext: string } { + // Check 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" }; + } + // Default to PNG + return { mimeType: "image/png", ext: "png" }; +} + /** * Upload a base64 image to Kie.ai and get a URL * Required for image-to-image models since Kie doesn't accept base64 directly - * Uses multipart/form-data with 'file' and 'uploadPath' fields + * Uses base64 upload endpoint (same as official Kie client) */ async function uploadImageToKie( requestId: string, @@ -1368,58 +1390,44 @@ async function uploadImageToKie( base64Image: string ): Promise { // Extract mime type and data from data URL - let mimeType = "image/png"; + let declaredMimeType = "image/png"; let imageData = base64Image; if (base64Image.startsWith("data:")) { const matches = base64Image.match(/^data:([^;]+);base64,(.+)$/); if (matches) { - mimeType = matches[1]; + declaredMimeType = matches[1]; imageData = matches[2]; } } - // Convert base64 to binary + // Convert base64 to binary to detect actual type const binaryData = Buffer.from(imageData, "base64"); - // Determine file extension - const ext = mimeType.includes("png") ? "png" : mimeType.includes("webp") ? "webp" : "jpg"; - const filename = `upload_${Date.now()}.${ext}`; - - console.log(`[API:${requestId}] Uploading image to Kie.ai: ${filename} (${(binaryData.length / 1024).toFixed(1)}KB)`); + // Detect actual image type from magic bytes (don't trust the declared MIME type) + const detected = detectImageType(binaryData); + const mimeType = detected.mimeType; + const ext = detected.ext; - // Build multipart form data manually - const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`; - const parts: Buffer[] = []; - - // Add file field - parts.push(Buffer.from( - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + - `Content-Type: ${mimeType}\r\n\r\n` - )); - parts.push(binaryData); - parts.push(Buffer.from('\r\n')); - - // Add uploadPath field - parts.push(Buffer.from( - `--${boundary}\r\n` + - `Content-Disposition: form-data; name="uploadPath"\r\n\r\n` + - `images\r\n` - )); + const filename = `upload_${Date.now()}.${ext}`; - // End boundary - parts.push(Buffer.from(`--${boundary}--\r\n`)); + console.log(`[API:${requestId}] Uploading image to Kie.ai: ${filename} (${(binaryData.length / 1024).toFixed(1)}KB) [declared: ${declaredMimeType}, actual: ${mimeType}]`); - const body = Buffer.concat(parts); + // Use base64 upload endpoint (same as official Kie client) + // Format: data:{mime_type};base64,{data} + const dataUrl = `data:${mimeType};base64,${imageData}`; - const response = await fetch("https://kieai.redpandaai.co/api/file-stream-upload", { + const response = await fetch("https://kieai.redpandaai.co/api/file-base64-upload", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, - "Content-Type": `multipart/form-data; boundary=${boundary}`, + "Content-Type": "application/json", }, - body: body, + body: JSON.stringify({ + base64Data: dataUrl, + uploadPath: "images", + fileName: filename, + }), }); if (!response.ok) { @@ -1430,6 +1438,11 @@ async function uploadImageToKie( 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; @@ -1479,23 +1492,24 @@ async function pollKieTaskCompletion( } const result = await response.json(); - const status = result.status || result.data?.status; + // Kie API returns "state" in result.data.state (not "status") + const state = (result.data?.state || result.state || result.status || "").toUpperCase(); - if (status !== lastStatus) { - console.log(`[API:${requestId}] Kie task status: ${status}`); - lastStatus = status; + if (state !== lastStatus) { + console.log(`[API:${requestId}] Kie task state: ${state}`); + lastStatus = state; } - if (status === "success" || status === "completed") { + if (state === "SUCCESS" || state === "COMPLETED") { return { success: true, data: result.data || result }; } - if (status === "fail" || status === "failed" || status === "error") { - const errorMessage = result.error || result.message || "Generation failed"; + if (state === "FAIL" || state === "FAILED" || state === "ERROR") { + 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. + // Continue polling for: WAITING, QUEUING, GENERATING, PROCESSING, etc. } } @@ -1553,9 +1567,25 @@ async function generateWithKie( if (value !== null && value !== undefined && value !== '') { // Check if this is an image input that needs uploading if (typeof value === 'string' && value.startsWith('data:image')) { + // Single data URL - upload it const url = await uploadImageToKie(requestId, apiKey, value); - // Image params should be arrays inputParams[key] = [url]; + } 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:image')) { + const url = await uploadImageToKie(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) { + inputParams[key] = processedArray; + } } else { inputParams[key] = value; } @@ -1596,7 +1626,16 @@ async function generateWithKie( : "https://api.kie.ai/api/v1/jobs/createTask"; console.log(`[API:${requestId}] Calling Kie.ai API: ${createUrl}`); - console.log(`[API:${requestId}] Request body:`, JSON.stringify(requestBody).substring(0, 500)); + // 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) }; + 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, { @@ -1674,9 +1713,21 @@ async function generateWithKie( 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) { - const resultJson = data.resultJson as Record | undefined; - const resultUrls = (resultJson?.resultUrls || data.resultUrls) as string[] | undefined; + 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; + } + } + + const resultUrls = ((resultJson as Record | undefined)?.resultUrls || data.resultUrls) as string[] | undefined; if (resultUrls && resultUrls.length > 0) { mediaUrl = resultUrls[0]; diff --git a/src/components/SplitGridSettingsModal.tsx b/src/components/SplitGridSettingsModal.tsx index f96d4cef..cb70bb2f 100644 --- a/src/components/SplitGridSettingsModal.tsx +++ b/src/components/SplitGridSettingsModal.tsx @@ -10,7 +10,7 @@ interface SplitGridSettingsModalProps { onClose: () => void; } -const TARGET_COUNT_OPTIONS = [4, 6, 8, 9, 10] as const; +const TARGET_COUNT_OPTIONS = [4, 5, 6, 8, 9, 10] as const; const ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]; const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"]; @@ -23,6 +23,7 @@ const MODELS: { value: ModelType; label: string }[] = [ const getGridDimensions = (count: number): { rows: number; cols: number } => { const grids: Record = { 4: { rows: 2, cols: 2 }, + 5: { rows: 1, cols: 5 }, // 1x5 vertical strip (e.g., for A+ content) 6: { rows: 2, cols: 3 }, 8: { rows: 2, cols: 4 }, 9: { rows: 3, cols: 3 }, diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 081c2ce2..11deaf31 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -483,194 +483,46 @@ export function GenerateImageNode({ id, data, selected }: NodeProps - {/* Dynamic input handles based on model schema (external providers only) */} - {!isGeminiProvider && nodeData.inputSchema && nodeData.inputSchema.length > 0 ? ( - // Render handles from schema, sorted by type (images first, text second) - // IMPORTANT: Always render "image" and "text" handles to maintain connection - // compatibility. Schema may only have text inputs (text-to-image models) but - // we still need the image handle to preserve connections made before model selection. - (() => { - const imageInputs = nodeData.inputSchema!.filter(i => i.type === "image"); - const textInputs = nodeData.inputSchema!.filter(i => i.type === "text"); - - // Always include at least one image and one text handle for connection stability - const hasImageInput = imageInputs.length > 0; - const hasTextInput = textInputs.length > 0; - - // Build the handles array: schema inputs + fallback defaults if missing - const handles: Array<{ - id: string; - type: "image" | "text"; - label: string; - schemaName: string | null; - description: string | null; - isPlaceholder: boolean; - }> = []; - - // Add image handles from schema, or a placeholder if none exist - if (hasImageInput) { - imageInputs.forEach((input, index) => { - handles.push({ - // Always use indexed IDs for schema inputs for consistency - id: `image-${index}`, - type: "image", - label: input.label, - schemaName: input.name, - description: input.description || null, - isPlaceholder: false, - }); - }); - } else { - // No image inputs in schema - add placeholder to preserve connections - handles.push({ - id: "image", - type: "image", - label: "Image", - schemaName: null, - description: "Not used by this model", - isPlaceholder: true, - }); - } - - // Add text handles from schema, or a placeholder if none exist - if (hasTextInput) { - textInputs.forEach((input, index) => { - handles.push({ - // Always use indexed IDs for schema inputs for consistency - id: `text-${index}`, - type: "text", - label: input.label, - schemaName: input.name, - description: input.description || null, - isPlaceholder: false, - }); - }); - } else { - // No text inputs in schema - add placeholder to preserve connections - handles.push({ - id: "text", - type: "text", - label: "Prompt", - schemaName: null, - description: "Not used by this model", - isPlaceholder: true, - }); - } - - // Calculate positions - const imageHandles = handles.filter(h => h.type === "image"); - const textHandles = handles.filter(h => h.type === "text"); - const totalSlots = imageHandles.length + textHandles.length + 1; // +1 for gap - - const renderedHandles = handles.map((handle, index) => { - // Position: images first, then gap, then text - const isImage = handle.type === "image"; - const typeIndex = isImage - ? imageHandles.findIndex(h => h.id === handle.id) - : textHandles.findIndex(h => h.id === handle.id); - const adjustedIndex = isImage ? typeIndex : imageHandles.length + 1 + typeIndex; - const topPercent = ((adjustedIndex + 1) / (totalSlots + 1)) * 100; - - return ( - - - {/* Handle label - positioned outside node, above the connector */} -
- {handle.label} -
-
- ); - }); - - // Add hidden backward-compatibility handles for edges using non-indexed IDs - // This ensures edges created with "image"/"text" still work when schema uses "image-0"/"text-0" - // Note: No data-handletype to avoid being counted in tests - these are purely for edge routing - return ( - <> - {renderedHandles} - {hasImageInput && ( - - )} - {hasTextInput && ( - - )} - - ); - })() - ) : ( - // Default handles for Gemini or when no schema - <> - - {/* Default image label */} -
- Image -
- - {/* Default text label */} -
- Prompt -
- - )} + {/* Input handles - ALWAYS use same IDs and positions for connection stability */} + {/* Image input at 35%, Text input at 65% - never changes regardless of model */} + + {/* Image label */} +
+ Image +
+ + {/* Prompt label */} +
+ Prompt +
{/* Image output */} { - if (!nodeData.isConfigured && nodeData.childNodeIds.length === 0) { + if (!nodeData.isConfigured && (!nodeData.childNodeIds || nodeData.childNodeIds.length === 0)) { setShowSettings(true); } - }, [nodeData.isConfigured, nodeData.childNodeIds.length]); + }, [nodeData.isConfigured, nodeData.childNodeIds]); const handleOpenSettings = useCallback(() => { setShowSettings(true); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 3a61a32e..6c3888d8 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1572,7 +1572,11 @@ export const useWorkflowStore = create((set, get) => ({ } case "llmGenerate": { - const { images, text } = getConnectedInputs(node.id); + const inputs = getConnectedInputs(node.id); + const images = inputs.images; + const llmData = node.data as LLMGenerateNodeData; + // Fall back to node's internal inputPrompt if no text connection + const text = inputs.text ?? llmData.inputPrompt; if (!text) { logger.error('node.error', 'llmGenerate node missing text input', { @@ -1580,7 +1584,7 @@ export const useWorkflowStore = create((set, get) => ({ }); updateNodeData(node.id, { status: "error", - error: "Missing text input", + error: "Missing text input - connect a prompt node or set internal prompt", }); set({ isRunning: false, currentNodeId: null }); await logger.endSession();