From 836ccd586c7573e9bc17e5d648a35a285420f83d Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 18 Feb 2026 19:45:19 +1300 Subject: [PATCH] feat: add header run button and fix waveform playback progress - Replace bottom "Regenerate" button with header run button (onRun prop) matching the pattern used by GenerateVideo and GenerateImage nodes - Add isExecuting and hasError props to BaseNode for visual feedback - Fix waveform playback progress: bars now change color to show played vs unplayed portions (violet-300 played, violet-400 unplayed) with a white position line - Add requestAnimationFrame loop for smooth playback position updates - Apply same waveform progress fix to AudioInputNode for consistency Co-Authored-By: Claude Opus 4.6 --- .../__tests__/GenerateAudioNode.test.tsx | 33 ++++----- src/components/nodes/AudioInputNode.tsx | 36 +++++----- src/components/nodes/GenerateAudioNode.tsx | 69 +++++++++++-------- 3 files changed, 68 insertions(+), 70 deletions(-) diff --git a/src/components/__tests__/GenerateAudioNode.test.tsx b/src/components/__tests__/GenerateAudioNode.test.tsx index db137ff2..1e786124 100644 --- a/src/components/__tests__/GenerateAudioNode.test.tsx +++ b/src/components/__tests__/GenerateAudioNode.test.tsx @@ -303,37 +303,31 @@ describe("GenerateAudioNode", () => { }); }); - describe("Regenerate Button", () => { - it("should show regenerate button when audio is complete", () => { + describe("Run Button", () => { + it("should show run button in header", () => { render( - + ); - expect(screen.getByText("Regenerate")).toBeInTheDocument(); + expect(screen.getByTitle("Run this node")).toBeInTheDocument(); }); - it("should call regenerateNode when regenerate button is clicked", () => { + it("should call regenerateNode when run button is clicked", () => { render( - + ); - const regenButton = screen.getByText("Regenerate"); - fireEvent.click(regenButton); + const runButton = screen.getByTitle("Run this node"); + fireEvent.click(runButton); expect(mockRegenerateNode).toHaveBeenCalledWith("test-audio-1"); }); - it("should disable regenerate button when workflow is running", () => { + it("should disable run button when workflow is running", () => { mockUseWorkflowStore.mockImplementation((selector) => { const state = { updateNodeData: mockUpdateNodeData, @@ -355,15 +349,12 @@ describe("GenerateAudioNode", () => { render( - + ); - const regenButton = screen.getByText("Regenerate"); - expect(regenButton).toBeDisabled(); + const runButton = screen.getByTitle("Run this node"); + expect(runButton).toBeDisabled(); }); }); diff --git a/src/components/nodes/AudioInputNode.tsx b/src/components/nodes/AudioInputNode.tsx index 327efbf4..cf8b6612 100644 --- a/src/components/nodes/AudioInputNode.tsx +++ b/src/components/nodes/AudioInputNode.tsx @@ -72,16 +72,15 @@ export function AudioInputNode({ id, data, selected }: NodeProps { + (ctx: CanvasRenderingContext2D, width: number, height: number, peaks: number[], progress?: number) => { ctx.clearRect(0, 0, width, height); const barCount = Math.min(peaks.length, width); const barWidth = width / barCount; const barGap = 1; - - ctx.fillStyle = "rgb(167, 139, 250)"; // violet-400 + const progressX = progress !== undefined ? progress * width : -1; for (let i = 0; i < barCount; i++) { const peakIndex = Math.floor((i / barCount) * peaks.length); @@ -90,8 +89,19 @@ export function AudioInputNode({ id, data, selected }: NodeProps 0) { + ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(progressX, 0); + ctx.lineTo(progressX, height); + ctx.stroke(); + } }, [] ); @@ -124,7 +134,7 @@ export function AudioInputNode({ id, data, selected }: NodeProps { if (!waveformData || !canvasRef.current) return; @@ -136,20 +146,8 @@ export function AudioInputNode({ id, data, selected }: NodeProps 0 ? currentTime / nodeData.duration : undefined; + drawWaveform(ctx, width, height, waveformData.peaks, progress); }, [isPlaying, currentTime, nodeData.duration, waveformData, drawWaveform]); // Animation loop for smooth playback position updates diff --git a/src/components/nodes/GenerateAudioNode.tsx b/src/components/nodes/GenerateAudioNode.tsx index f6416ee1..f3bcaef9 100644 --- a/src/components/nodes/GenerateAudioNode.tsx +++ b/src/components/nodes/GenerateAudioNode.tsx @@ -26,6 +26,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps(null); const canvasRef = useRef(null); const waveformContainerRef = useRef(null); + const animationFrameRef = useRef(undefined); // Get the current selected provider (default to fal) const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal"; @@ -84,16 +85,15 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps { + (ctx: CanvasRenderingContext2D, width: number, height: number, peaks: number[], progress?: number) => { ctx.clearRect(0, 0, width, height); const barCount = Math.min(peaks.length, width); const barWidth = width / barCount; const barGap = 1; - - ctx.fillStyle = "rgb(167, 139, 250)"; // violet-400 + const progressX = progress !== undefined ? progress * width : -1; for (let i = 0; i < barCount; i++) { const peakIndex = Math.floor((i / barCount) * peaks.length); @@ -102,8 +102,19 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps 0) { + ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(progressX, 0); + ctx.lineTo(progressX, height); + ctx.stroke(); + } }, [] ); @@ -136,7 +147,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps { if (!waveformData || !canvasRef.current) return; @@ -148,21 +159,28 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps 0 ? currentTime / nodeData.duration : undefined; + drawWaveform(ctx, width, height, waveformData.peaks, progress); + }, [isPlaying, currentTime, nodeData.duration, waveformData, drawWaveform]); - ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, height); - ctx.stroke(); + // Animation loop for smooth playback position updates + useEffect(() => { + if (isPlaying && audioRef.current) { + const updatePosition = () => { + if (audioRef.current) { + setCurrentTime(audioRef.current.currentTime); + } + animationFrameRef.current = requestAnimationFrame(updatePosition); + }; + animationFrameRef.current = requestAnimationFrame(updatePosition); } - }, [isPlaying, currentTime, nodeData.duration, waveformData, drawWaveform]); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isPlaying]); const handleClearAudio = useCallback(() => { if (audioRef.current) { @@ -368,7 +386,10 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps updateNodeData(id, { customTitle: title || undefined })} onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + onRun={handleRegenerate} selected={selected} + isExecuting={isRunning} + hasError={nodeData.status === "error"} commentNavigation={commentNavigation ?? undefined} minWidth={300} minHeight={250} @@ -496,18 +517,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps )} - {/* Regenerate button */} - {nodeData.status === "complete" && nodeData.outputAudio && ( - - )} - {/* Dynamic handles from schema */} {dynamicHandles}