diff --git a/src/app/api/generate/providers/fal.ts b/src/app/api/generate/providers/fal.ts index 7866369f..95f43852 100644 --- a/src/app/api/generate/providers/fal.ts +++ b/src/app/api/generate/providers/fal.ts @@ -531,9 +531,12 @@ export async function generateWithFalQueue( }; } - // For audio models, always base64 encode (audio files are small) - if (isAudioModel) { - const audioContentType = mediaResponse.headers.get("content-type") || "audio/mpeg"; + // Detect actual media type from response content-type, falling back to model hints + const rawContentType = mediaResponse.headers.get("content-type") || ""; + const isAudioResponse = rawContentType.startsWith("audio/") || (!rawContentType.startsWith("video/") && !rawContentType.startsWith("image/") && isAudioModel); + + if (isAudioResponse) { + const audioContentType = rawContentType.startsWith("audio/") ? rawContentType : "audio/mpeg"; const audioBuffer = await mediaResponse.arrayBuffer(); const audioBase64 = Buffer.from(audioBuffer).toString("base64"); console.log(`[API:${requestId}] SUCCESS - Returning audio`); @@ -547,7 +550,7 @@ export async function generateWithFalQueue( }; } - const contentType = mediaResponse.headers.get("content-type") || (isVideoModel ? "video/mp4" : "image/png"); + const contentType = rawContentType || (isVideoModel ? "video/mp4" : "image/png"); const isVideo = contentType.startsWith("video/"); const mediaArrayBuffer = await mediaResponse.arrayBuffer(); diff --git a/src/app/api/generate/providers/kie.ts b/src/app/api/generate/providers/kie.ts index a9162a85..61b38609 100644 --- a/src/app/api/generate/providers/kie.ts +++ b/src/app/api/generate/providers/kie.ts @@ -812,11 +812,13 @@ 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; fall back to URL/capability detection only when ambiguous + // Content-type wins over URL-based detection; reset opposite flag to avoid conflicts if (rawContentType.startsWith("video/")) { isVideo = true; + isAudio = false; } else if (rawContentType.startsWith("audio/")) { isAudio = true; + isVideo = false; } else if (!isConcreteMedia && !isVideo && !isAudio && isAudioModel) { isAudio = true; } diff --git a/src/app/api/workflow-images/route.ts b/src/app/api/workflow-images/route.ts index 4fa71343..1436141d 100644 --- a/src/app/api/workflow-images/route.ts +++ b/src/app/api/workflow-images/route.ts @@ -169,6 +169,15 @@ export async function GET(request: NextRequest) { } try { + // Sanitize imageId to prevent path traversal + const safeImageId = path.basename(imageId); + if (safeImageId !== imageId || imageId.includes('..')) { + return NextResponse.json( + { success: false, error: "Invalid imageId" }, + { status: 400 } + ); + } + // Validate workflow directory exists try { const stats = await fs.stat(workflowPath); @@ -202,7 +211,7 @@ export async function GET(request: NextRequest) { // Check each folder and extension combination in order for (const searchFolder of searchOrder) { for (const ext of possibleExtensions) { - const filename = `${imageId}.${ext}`; + const filename = `${safeImageId}.${ext}`; const candidatePath = path.join(searchFolder, filename); try { await fs.access(candidatePath); diff --git a/src/components/nodes/Generate3DNode.tsx b/src/components/nodes/Generate3DNode.tsx index 4d747b82..732140ab 100644 --- a/src/components/nodes/Generate3DNode.tsx +++ b/src/components/nodes/Generate3DNode.tsx @@ -334,15 +334,23 @@ export function Generate3DNode({ id, data, selected }: NodeProps { e.stopPropagation(); - if (!nodeData.savedFilePath) return; + if (!nodeData.savedFilePath) { + useToast.getState().show("No file path available", "error"); + return; + } try { - await fetch("/api/open-file", { + const res = await fetch("/api/open-file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filePath: nodeData.savedFilePath }), }); + if (!res.ok) { + const detail = await res.text().catch(() => `Status ${res.status}`); + useToast.getState().show("Failed to open file", "error", true, detail); + } } catch (err) { console.error("Failed to open file location:", err); + useToast.getState().show("Failed to open file location", "error"); } }} className="nodrag nopan text-[10px] text-neutral-400 hover:text-orange-300 truncate max-w-full cursor-pointer transition-colors flex items-center gap-1" diff --git a/src/components/nodes/VideoStitchNode.tsx b/src/components/nodes/VideoStitchNode.tsx index 66859b62..29a1d3dc 100644 --- a/src/components/nodes/VideoStitchNode.tsx +++ b/src/components/nodes/VideoStitchNode.tsx @@ -169,7 +169,8 @@ export function VideoStitchNode({ id, data, selected }: NodeProps 0 ? video.videoWidth / video.videoHeight : 0; + const aspectRatio = Number.isFinite(rawAspectRatio) && rawAspectRatio > 0 ? rawAspectRatio : 16 / 9; canvas.width = thumbWidth; canvas.height = Math.round(thumbWidth / aspectRatio); const ctx = canvas.getContext("2d"); diff --git a/src/hooks/useStitchVideos.ts b/src/hooks/useStitchVideos.ts index be4307b5..e441cf8a 100644 --- a/src/hooks/useStitchVideos.ts +++ b/src/hooks/useStitchVideos.ts @@ -261,6 +261,7 @@ export async function stitchVideosAsync( await output.start(); outputStarted = true; + try { // Feed audio early so the muxer can interleave audio packets with video frames. // Writing audio after all video causes broken interleaving (Discord won't play audio). if (audioSource && pendingAudioBuffer) { @@ -273,7 +274,6 @@ export async function stitchVideosAsync( pendingAudioBuffer = null; } - try { // Track the highest timestamp we've written to ensure monotonicity // Start at -frameInterval so first frame can be at timestamp 0 const frameInterval = 1 / MAX_OUTPUT_FPS; diff --git a/src/store/execution/generateAudioExecutor.ts b/src/store/execution/generateAudioExecutor.ts index 5a5aa87f..ee3d6351 100644 --- a/src/store/execution/generateAudioExecutor.ts +++ b/src/store/execution/generateAudioExecutor.ts @@ -73,7 +73,7 @@ export async function executeGenerateAudio( } updateNodeData(node.id, { - inputPrompt: text, + inputPrompt: text ?? nodeData.inputPrompt, status: "loading", error: null, }); diff --git a/src/store/execution/videoProcessingExecutors.ts b/src/store/execution/videoProcessingExecutors.ts index 13a8c7af..edb20225 100644 --- a/src/store/execution/videoProcessingExecutors.ts +++ b/src/store/execution/videoProcessingExecutors.ts @@ -372,7 +372,14 @@ export async function executeVideoFrameGrab(ctx: NodeExecutionContext): Promise< let blobUrl: string | null = null; try { + const FRAME_EXTRACTION_TIMEOUT = 30_000; // 30 seconds const outputImage = await new Promise((resolve, reject) => { + let timeoutId: ReturnType | null = null; + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId); + }; + video.onloadedmetadata = () => { // For "first" frame, seek to 0.001 (not exactly 0 to ensure a decoded frame) // For "last" frame, seek to duration - small epsilon @@ -380,9 +387,15 @@ export async function executeVideoFrameGrab(ctx: NodeExecutionContext): Promise< ? 0.001 : Math.max(0, video.duration - 0.1); video.currentTime = seekTime; + + timeoutId = setTimeout(() => { + if (blobUrl) URL.revokeObjectURL(blobUrl); + reject(new Error("Frame extraction timed out")); + }, FRAME_EXTRACTION_TIMEOUT); }; video.onseeked = () => { + cleanup(); try { const canvas = document.createElement("canvas"); canvas.width = video.videoWidth; @@ -401,6 +414,7 @@ export async function executeVideoFrameGrab(ctx: NodeExecutionContext): Promise< }; video.onerror = () => { + cleanup(); reject(new Error("Failed to load video for frame extraction")); };