From e920ed27ada1f68f3d054d9c8e7c0e8df01e90d2 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Mon, 20 Apr 2026 06:33:59 +1200 Subject: [PATCH] fix: misc fixes across CSS, tutorial, stitching, execution, and polling - Add --handle-color-video CSS variable - Fix tutorial overlay race conditions by reading nodes from store directly and add keyboard accessibility to click-to-continue overlay - Fix audio extraction in video stitching: defer silent buffer creation until reference format is known, and only trust probed duration when all blobs were successfully probed - Clear saved filename/path when clearing 3D output - Retry 429/408 transient errors during task polling Co-Authored-By: Claude Opus 4.6 --- src/app/globals.css | 1 + src/components/onboarding/TutorialOverlay.tsx | 54 +++++++---- src/hooks/useStitchVideos.ts | 90 ++++++++++++------- src/store/execution/generate3dExecutor.ts | 2 +- src/store/execution/pollTaskCompletion.ts | 8 +- 5 files changed, 102 insertions(+), 53 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 49728aa7..ad237135 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -12,6 +12,7 @@ --handle-color-text: #3b82f6; --handle-color-3d: #f97316; --handle-color-audio: #a855f7; + --handle-color-video: #a855f7; } html { diff --git a/src/components/onboarding/TutorialOverlay.tsx b/src/components/onboarding/TutorialOverlay.tsx index c4a32511..9a56bddd 100644 --- a/src/components/onboarding/TutorialOverlay.tsx +++ b/src/components/onboarding/TutorialOverlay.tsx @@ -165,7 +165,17 @@ export function TutorialOverlay() { } }, [tutorialActive, currentTutorialStep, tutorialSteps]); - // Auto-populate nodes during the populate-content step + // Reset populate ref when tutorial ends + useEffect(() => { + if (!tutorialActive) { + nodesPopulated.current = false; + } + }, [tutorialActive]); + + // Auto-populate nodes during the populate-content step. + // Reads nodes from the store directly (via getState) to avoid depending on + // `nodes` — which would retrigger the effect and clear the advance timer + // when updateNodeData mutates node data. useEffect(() => { if (!tutorialActive || nodesPopulated.current) { return; @@ -181,9 +191,10 @@ export function TutorialOverlay() { // Wait a bit before populating to show the message first timeoutIds.push(setTimeout(() => { - // Find the image input and prompt nodes - const imageInputNode = nodes.find((node) => node.type === "imageInput"); - const promptNode = nodes.find((node) => node.type === "prompt"); + // Read nodes from the store directly to avoid dependency on `nodes` + const currentNodes = useWorkflowStore.getState().nodes; + const imageInputNode = currentNodes.find((node) => node.type === "imageInput"); + const promptNode = currentNodes.find((node) => node.type === "prompt"); // Populate with sample content if (imageInputNode) { @@ -212,12 +223,14 @@ export function TutorialOverlay() { timeoutIds.length = 0; }; } + }, [tutorialActive, currentTutorialStep, tutorialSteps, tutorialSampleImage, updateNodeData, completeCurrentStep, nextTutorialStep]); - // Reset ref when tutorial ends + // Reset demonstrate ref when tutorial ends + useEffect(() => { if (!tutorialActive) { - nodesPopulated.current = false; + demonstrateNodesAdded.current = false; } - }, [tutorialActive, currentTutorialStep, tutorialSteps, nodes, tutorialSampleImage, updateNodeData, completeCurrentStep, nextTutorialStep]); + }, [tutorialActive]); // Auto-add downstream demonstration nodes useEffect(() => { @@ -229,9 +242,10 @@ export function TutorialOverlay() { if (currentStep?.id === "demonstrate-downstream" && !currentStep.completed) { demonstrateNodesAdded.current = true; - const addNode = useWorkflowStore.getState().addNode; - const onConnect = useWorkflowStore.getState().onConnect; - const updateNodeData = useWorkflowStore.getState().updateNodeData; + const storeState = useWorkflowStore.getState(); + const addNode = storeState.addNode; + const onConnect = storeState.onConnect; + const updateNodeData = storeState.updateNodeData; // Track all timeouts so cleanup can clear them if tutorial is skipped const timeoutIds = demonstrateTimeoutIds.current; @@ -242,8 +256,10 @@ export function TutorialOverlay() { // Initial delay to show message schedule(() => { + // Read nodes from the store directly to avoid dependency on `nodes` + const currentNodes = useWorkflowStore.getState().nodes; // Find the Generate Image node - const generateNode = nodes.find((n) => n.type === "nanoBanana"); + const generateNode = currentNodes.find((n) => n.type === "nanoBanana"); if (!generateNode) return; const baseX = generateNode.position.x; @@ -412,12 +428,7 @@ export function TutorialOverlay() { timeoutIds.length = 0; }; } - - // Reset ref when tutorial ends - if (!tutorialActive) { - demonstrateNodesAdded.current = false; - } - }, [tutorialActive, currentTutorialStep, tutorialSteps, nodes, completeCurrentStep, nextTutorialStep]); + }, [tutorialActive, currentTutorialStep, tutorialSteps, completeCurrentStep, nextTutorialStep]); // Don't render during SSR or when tutorial is inactive if (!mounted || !tutorialActive || currentTutorialStep >= tutorialSteps.length) { @@ -436,7 +447,16 @@ export function TutorialOverlay() { {/* Click-to-continue overlay (when waitForClick is true) */} {currentStep.waitForClick && (
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleContinue(); + } + }} + aria-label="Click to continue tutorial" className="fixed inset-0 cursor-pointer" style={{ zIndex: 92 }} /> diff --git a/src/hooks/useStitchVideos.ts b/src/hooks/useStitchVideos.ts index 8d128635..aebe5ff5 100644 --- a/src/hooks/useStitchVideos.ts +++ b/src/hooks/useStitchVideos.ts @@ -29,11 +29,12 @@ import { const determineEncodeParameters = async ( blobs: Blob[] -): Promise<{ width: number; height: number; rotation: Rotation; maxSourceBitrate: number; totalDuration: number }> => { +): Promise<{ width: number; height: number; rotation: Rotation; maxSourceBitrate: number; totalDuration: number; probeSuccessCount: number }> => { let maxWidth = 0; let maxHeight = 0; let maxSourceBitrate = 0; let totalDuration = 0; + let probeSuccessCount = 0; let rotation: Rotation | null = null; for (let i = 0; i < blobs.length; i++) { @@ -43,6 +44,7 @@ const determineEncodeParameters = async ( maxHeight = Math.max(maxHeight, height); maxSourceBitrate = Math.max(maxSourceBitrate, bitrate); totalDuration += duration; + probeSuccessCount++; if (rotation === null) { rotation = trackRotation; } else if (trackRotation !== rotation) { @@ -62,6 +64,7 @@ const determineEncodeParameters = async ( rotation: rotation ?? (0 as Rotation), maxSourceBitrate, totalDuration, + probeSuccessCount, }; } @@ -71,6 +74,7 @@ const determineEncodeParameters = async ( rotation: rotation ?? (0 as Rotation), maxSourceBitrate, totalDuration, + probeSuccessCount, }; }; @@ -175,6 +179,7 @@ export async function stitchVideosAsync( rotation: aggregateRotation, maxSourceBitrate, totalDuration: probedVideoDuration, + probeSuccessCount, } = await determineEncodeParameters(videoBlobs); const safeWidth = probedWidth > 0 ? probedWidth : FALLBACK_WIDTH; @@ -216,7 +221,9 @@ export async function stitchVideosAsync( let effectiveAudioData = audioData ?? null; if (!effectiveAudioData) { updateProgress('processing', 'Extracting audio from source videos...', 8); - const allAudioBuffers: AudioBuffer[] = []; + // Per-clip audio: each entry is either extracted buffers or a pending + // duration (seconds) for clips before the reference format is known. + const perClipAudio: Array<{ buffers: AudioBuffer[] } | { silentDuration: number }> = []; let referenceSampleRate: number | null = null; let referenceChannels: number | null = null; @@ -231,6 +238,7 @@ export async function stitchVideosAsync( if (audioTracks.length > 0) { const audioTrack = audioTracks[0]; const sink = new AudioBufferSink(audioTrack); + const clipBuffers: AudioBuffer[] = []; for await (const wrapped of sink.buffers(0, clipDuration)) { if (referenceSampleRate === null) { referenceSampleRate = wrapped.buffer.sampleRate; @@ -240,21 +248,18 @@ export async function stitchVideosAsync( wrapped.buffer.sampleRate === referenceSampleRate && wrapped.buffer.numberOfChannels === referenceChannels ) { - allAudioBuffers.push(wrapped.buffer); + clipBuffers.push(wrapped.buffer); extractedAudio = true; } } + if (extractedAudio) { + perClipAudio.push({ buffers: clipBuffers }); + } } - // 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); + // If no audio was extracted, record the clip's duration so we can + // insert silence later (even before the reference format is known). + if (!extractedAudio && clipDuration > 0) { + perClipAudio.push({ silentDuration: clipDuration }); } } finally { input.dispose(); @@ -264,26 +269,46 @@ export async function stitchVideosAsync( } } - if (allAudioBuffers.length > 0 && referenceSampleRate && referenceChannels) { - const totalSamples = allAudioBuffers.reduce((sum, b) => sum + b.length, 0); - const concatenated = new AudioBuffer({ - length: Math.max(1, totalSamples), - numberOfChannels: referenceChannels, - sampleRate: referenceSampleRate, - }); - - let offset = 0; - for (const buffer of allAudioBuffers) { - for (let ch = 0; ch < referenceChannels; ch++) { - concatenated.getChannelData(ch).set(buffer.getChannelData(ch), offset); + // Build the final concatenated buffer, resolving deferred silent entries + // now that the reference format is established. + if (referenceSampleRate && referenceChannels) { + const allAudioBuffers: AudioBuffer[] = []; + for (const entry of perClipAudio) { + if ('buffers' in entry) { + allAudioBuffers.push(...entry.buffers); + } else { + // Deferred silent clip — create a silent buffer with the reference format + const silentSamples = Math.max(1, Math.floor(entry.silentDuration * referenceSampleRate)); + const silentBuffer = new AudioBuffer({ + length: silentSamples, + numberOfChannels: referenceChannels, + sampleRate: referenceSampleRate, + }); + allAudioBuffers.push(silentBuffer); } - offset += buffer.length; } - effectiveAudioData = { - buffer: concatenated, - duration: totalSamples / referenceSampleRate, - }; + if (allAudioBuffers.length > 0) { + const totalSamples = allAudioBuffers.reduce((sum, b) => sum + b.length, 0); + const concatenated = new AudioBuffer({ + length: Math.max(1, totalSamples), + numberOfChannels: referenceChannels, + sampleRate: referenceSampleRate, + }); + + let offset = 0; + for (const buffer of allAudioBuffers) { + for (let ch = 0; ch < referenceChannels; ch++) { + concatenated.getChannelData(ch).set(buffer.getChannelData(ch), offset); + } + offset += buffer.length; + } + + effectiveAudioData = { + buffer: concatenated, + duration: totalSamples / referenceSampleRate, + }; + } } } @@ -343,7 +368,10 @@ export async function stitchVideosAsync( // Writing audio after all video causes broken interleaving (Discord won't play audio). if (audioSource && pendingAudioBuffer) { updateProgress('processing', 'Encoding audio track...', 12); - const trimTarget = probedVideoDuration > 0 ? probedVideoDuration : effectiveAudioData!.duration; + // Only trust probedVideoDuration when all blobs were successfully probed; + // a partial sum would prematurely trim the audio track. + const allProbed = probeSuccessCount === videoBlobs.length; + const trimTarget = allProbed && probedVideoDuration > 0 ? probedVideoDuration : effectiveAudioData!.duration; const trimmedBuffer = trimAudioBuffer(pendingAudioBuffer, trimTarget); await audioSource.add(trimmedBuffer); await audioSource.close(); diff --git a/src/store/execution/generate3dExecutor.ts b/src/store/execution/generate3dExecutor.ts index 588ee36a..d52d4a1f 100644 --- a/src/store/execution/generate3dExecutor.ts +++ b/src/store/execution/generate3dExecutor.ts @@ -220,6 +220,6 @@ export async function executeGenerate3D( fallbackParameters: nodeData.fallbackParameters, updateNodeData, runOnce, - clearOutput: { output3dUrl: null }, + clearOutput: { output3dUrl: null, savedFilename: null, savedFilePath: null }, }); } diff --git a/src/store/execution/pollTaskCompletion.ts b/src/store/execution/pollTaskCompletion.ts index 31140450..175838c2 100644 --- a/src/store/execution/pollTaskCompletion.ts +++ b/src/store/execution/pollTaskCompletion.ts @@ -87,10 +87,10 @@ export async function pollGenerateTask( } if (!response.ok) { - // Server errors during polling may be transient - if (response.status >= 500) { + // Server errors and transient HTTP statuses may be retried + if (response.status >= 500 || response.status === 429 || response.status === 408) { consecutiveErrors++; - console.warn(`[poll] Server error ${response.status} (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS})`); + console.warn(`[poll] Transient error ${response.status} (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS})`); if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { const errorText = await response.text().catch(() => ""); return { success: false, error: `${modelName}: Polling failed - ${errorText || `HTTP ${response.status}`}` }; @@ -99,7 +99,7 @@ export async function pollGenerateTask( continue; } - // 4xx errors are not recoverable + // Other 4xx errors are not recoverable const errorText = await response.text().catch(() => ""); let errorMessage = `HTTP ${response.status}`; try {