diff --git a/src/components/onboarding/TutorialOverlay.tsx b/src/components/onboarding/TutorialOverlay.tsx index f6293e50..c4a32511 100644 --- a/src/components/onboarding/TutorialOverlay.tsx +++ b/src/components/onboarding/TutorialOverlay.tsx @@ -112,12 +112,15 @@ export function TutorialOverlay() { } if (actionCompleted) { - completeCurrentStep(); - // Advance to next step after configurable delay (default 1000ms) + // Advance to next step after configurable delay (default 1000ms). + // Both completeCurrentStep() and nextTutorialStep() are called inside + // the timeout so that the state update doesn't trigger a re-render + // whose cleanup would clear the pending timeout. const delay = currentStep.advanceDelay !== undefined ? currentStep.advanceDelay : 1000; if (advanceTimeoutRef.current) clearTimeout(advanceTimeoutRef.current); advanceTimeoutRef.current = setTimeout(() => { advanceTimeoutRef.current = null; + completeCurrentStep(); nextTutorialStep(); }, delay); } diff --git a/src/hooks/useStitchVideos.ts b/src/hooks/useStitchVideos.ts index cce63fbe..8d128635 100644 --- a/src/hooks/useStitchVideos.ts +++ b/src/hooks/useStitchVideos.ts @@ -224,13 +224,14 @@ export async function stitchVideosAsync( try { const blobSource = new BlobSource(videoBlobs[i]); const input = new Input({ source: blobSource, formats: ALL_FORMATS }); + let extractedAudio = false; try { + const clipDuration = await input.computeDuration(); const audioTracks = await input.getAudioTracks(); if (audioTracks.length > 0) { const audioTrack = audioTracks[0]; const sink = new AudioBufferSink(audioTrack); - const duration = await input.computeDuration(); - for await (const wrapped of sink.buffers(0, duration)) { + for await (const wrapped of sink.buffers(0, clipDuration)) { if (referenceSampleRate === null) { referenceSampleRate = wrapped.buffer.sampleRate; referenceChannels = wrapped.buffer.numberOfChannels; @@ -240,9 +241,21 @@ export async function stitchVideosAsync( wrapped.buffer.numberOfChannels === referenceChannels ) { allAudioBuffers.push(wrapped.buffer); + extractedAudio = true; } } } + // If no audio was extracted (no tracks or incompatible params), + // push a silent buffer to maintain timeline alignment + if (!extractedAudio && referenceSampleRate && referenceChannels && clipDuration > 0) { + const silentSamples = Math.max(1, Math.floor(clipDuration * referenceSampleRate)); + const silentBuffer = new AudioBuffer({ + length: silentSamples, + numberOfChannels: referenceChannels, + sampleRate: referenceSampleRate, + }); + allAudioBuffers.push(silentBuffer); + } } finally { input.dispose(); } diff --git a/src/store/execution/generate3dExecutor.ts b/src/store/execution/generate3dExecutor.ts index b92c9ef5..588ee36a 100644 --- a/src/store/execution/generate3dExecutor.ts +++ b/src/store/execution/generate3dExecutor.ts @@ -137,6 +137,8 @@ export async function executeGenerate3D( output3dUrl: result.model3dUrl, status: "complete", error: null, + savedFilename: null, + savedFilePath: null, }); // Track cost if applicable diff --git a/src/store/execution/pollTaskCompletion.ts b/src/store/execution/pollTaskCompletion.ts index d31494c4..31140450 100644 --- a/src/store/execution/pollTaskCompletion.ts +++ b/src/store/execution/pollTaskCompletion.ts @@ -50,19 +50,16 @@ export async function pollGenerateTask( // Wait before polling await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, interval); + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException("The operation was aborted.", "AbortError")); + }; + const timer = setTimeout(() => { + if (signal) signal.removeEventListener("abort", onAbort); + resolve(); + }, interval); if (signal) { - const onAbort = () => { - clearTimeout(timer); - reject(new DOMException("The operation was aborted.", "AbortError")); - }; signal.addEventListener("abort", onAbort, { once: true }); - // Clean up listener when timer fires - const origResolve = resolve; - resolve = () => { - signal.removeEventListener("abort", onAbort); - origResolve(); - }; } }); diff --git a/src/store/utils/connectedInputs.ts b/src/store/utils/connectedInputs.ts index df2ce9c1..4f7e07db 100644 --- a/src/store/utils/connectedInputs.ts +++ b/src/store/utils/connectedInputs.ts @@ -403,11 +403,13 @@ export function validateWorkflowPure( } // Check each Nano Banana node has required inputs (text required, image optional) + // Loop edges are excluded because they carry no data on the first iteration. nodes .filter((n) => n.type === "nanoBanana") .forEach((node) => { const textConnected = edges.some( (e) => e.target === node.id && + !e.data?.isLoop && (e.targetHandle === "text" || e.targetHandle?.startsWith("text-")) ); if (!textConnected) { @@ -421,6 +423,7 @@ export function validateWorkflowPure( .forEach((node) => { const textConnected = edges.some( (e) => e.target === node.id && + !e.data?.isLoop && (e.targetHandle === "text" || e.targetHandle?.startsWith("text-")) ); if (!textConnected) {