Browse Source

fix: parse Kie.ai resultJson string and add 1x5 grid option

- Fix Kie.ai GPT Image 1.5 failing to retrieve output URL by parsing
  resultJson as JSON string (was treated as object incorrectly)
- Add 1x5 grid option to SplitGridNode for vertical contact sheets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
lapoaiscalers 5 months ago
parent
commit
da0bca45e3
  1. 143
      src/app/api/generate/route.ts
  2. 3
      src/components/SplitGridSettingsModal.tsx
  3. 228
      src/components/nodes/GenerateImageNode.tsx
  4. 4
      src/components/nodes/SplitGridNode.tsx
  5. 8
      src/store/workflowStore.ts

143
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<string> {
// 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<string, unknown>) };
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<string, unknown> | undefined;
const resultUrls = (resultJson?.resultUrls || data.resultUrls) as 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') {
try {
resultJson = JSON.parse(resultJson) as Record<string, unknown>;
} catch {
// Not valid JSON, keep as-is
resultJson = undefined;
}
}
const resultUrls = ((resultJson as Record<string, unknown> | undefined)?.resultUrls || data.resultUrls) as string[] | undefined;
if (resultUrls && resultUrls.length > 0) {
mediaUrl = resultUrls[0];

3
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<number, { rows: number; cols: number }> = {
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 },

228
src/components/nodes/GenerateImageNode.tsx

@ -483,194 +483,46 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
titlePrefix={titlePrefix}
commentNavigation={commentNavigation ?? undefined}
>
{/* 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 (
<React.Fragment key={handle.id}>
<Handle
type="target"
position={Position.Left}
id={handle.id}
style={{
top: `${topPercent}%`,
opacity: handle.isPlaceholder ? 0.3 : 1,
}}
data-handletype={handle.type}
data-schema-name={handle.schemaName || undefined}
isConnectable={true}
title={handle.description || handle.label}
/>
{/* Handle label - positioned outside node, above the connector */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: `calc(${topPercent}% - 18px)`,
color: isImage ? "var(--handle-color-image)" : "var(--handle-color-text)",
opacity: handle.isPlaceholder ? 0.3 : 1,
}}
>
{handle.label}
</div>
</React.Fragment>
);
});
// 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 && (
<Handle
type="target"
position={Position.Left}
id="image"
style={{ top: "35%", opacity: 0, pointerEvents: "none" }}
isConnectable={false}
/>
)}
{hasTextInput && (
<Handle
type="target"
position={Position.Left}
id="text"
style={{ top: "65%", opacity: 0, pointerEvents: "none" }}
isConnectable={false}
/>
)}
</>
);
})()
) : (
// Default handles for Gemini or when no schema
<>
<Handle
type="target"
position={Position.Left}
id="image"
style={{ top: "35%" }}
data-handletype="image"
isConnectable={true}
/>
{/* Default image label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: "calc(35% - 18px)",
color: "var(--handle-color-image)",
}}
>
Image
</div>
<Handle
type="target"
position={Position.Left}
id="text"
style={{ top: "65%" }}
data-handletype="text"
/>
{/* Default text label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: "calc(65% - 18px)",
color: "var(--handle-color-text)",
}}
>
Prompt
</div>
</>
)}
{/* Input handles - ALWAYS use same IDs and positions for connection stability */}
{/* Image input at 35%, Text input at 65% - never changes regardless of model */}
<Handle
type="target"
position={Position.Left}
id="image"
style={{ top: "35%" }}
data-handletype="image"
isConnectable={true}
/>
{/* Image label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: "calc(35% - 18px)",
color: "var(--handle-color-image)",
}}
>
Image
</div>
<Handle
type="target"
position={Position.Left}
id="text"
style={{ top: "65%" }}
data-handletype="text"
isConnectable={true}
/>
{/* Prompt label */}
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: "calc(65% - 18px)",
color: "var(--handle-color-text)",
}}
>
Prompt
</div>
{/* Image output */}
<Handle
type="source"

4
src/components/nodes/SplitGridNode.tsx

@ -20,10 +20,10 @@ export function SplitGridNode({ id, data, selected }: NodeProps<SplitGridNodeTyp
// Show settings modal on first creation (when not configured)
useEffect(() => {
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);

8
src/store/workflowStore.ts

@ -1572,7 +1572,11 @@ export const useWorkflowStore = create<WorkflowStore>((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<WorkflowStore>((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();

Loading…
Cancel
Save