Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
e920ed27ad
  1. 1
      src/app/globals.css
  2. 54
      src/components/onboarding/TutorialOverlay.tsx
  3. 90
      src/hooks/useStitchVideos.ts
  4. 2
      src/store/execution/generate3dExecutor.ts
  5. 8
      src/store/execution/pollTaskCompletion.ts

1
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 {

54
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 && (
<div
role="button"
tabIndex={0}
onClick={handleContinue}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleContinue();
}
}}
aria-label="Click to continue tutorial"
className="fixed inset-0 cursor-pointer"
style={{ zIndex: 92 }}
/>

90
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();

2
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 },
});
}

8
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 {

Loading…
Cancel
Save