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 {