Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
836ccd586c
  1. 33
      src/components/__tests__/GenerateAudioNode.test.tsx
  2. 36
      src/components/nodes/AudioInputNode.tsx
  3. 69
      src/components/nodes/GenerateAudioNode.tsx

33
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(
<TestWrapper>
<GenerateAudioNode {...createNodeProps({
outputAudio: "data:audio/mp3;base64,abc123",
status: "complete",
})} />
<GenerateAudioNode {...createNodeProps()} />
</TestWrapper>
);
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(
<TestWrapper>
<GenerateAudioNode {...createNodeProps({
outputAudio: "data:audio/mp3;base64,abc123",
status: "complete",
})} />
<GenerateAudioNode {...createNodeProps()} />
</TestWrapper>
);
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(
<TestWrapper>
<GenerateAudioNode {...createNodeProps({
outputAudio: "data:audio/mp3;base64,abc123",
status: "complete",
})} />
<GenerateAudioNode {...createNodeProps()} />
</TestWrapper>
);
const regenButton = screen.getByText("Regenerate");
expect(regenButton).toBeDisabled();
const runButton = screen.getByTitle("Run this node");
expect(runButton).toBeDisabled();
});
});

36
src/components/nodes/AudioInputNode.tsx

@ -72,16 +72,15 @@ export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeT
};
}, [nodeData.audioFile]);
// Helper to draw waveform bars on canvas
// Helper to draw waveform bars on canvas with optional progress indicator
const drawWaveform = useCallback(
(ctx: CanvasRenderingContext2D, width: number, height: number, peaks: number[]) => {
(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<AudioInputNodeT
const x = i * barWidth;
const y = (height - barHeight) / 2;
ctx.fillStyle = x < progressX ? "rgb(196, 181, 253)" : "rgb(167, 139, 250)"; // violet-300 played, violet-400 unplayed
ctx.fillRect(x, y, barWidth - barGap, barHeight);
}
// Draw playback position line
if (progressX > 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<AudioInputNodeT
};
}, [waveformData, drawWaveform]);
// Effect B: Redraw waveform + playback position (lightweight, no ResizeObserver)
// Effect B: Redraw waveform + playback progress (lightweight, no ResizeObserver)
useEffect(() => {
if (!waveformData || !canvasRef.current) return;
@ -136,20 +146,8 @@ export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeT
const height = canvas.height;
if (width === 0 || height === 0) return;
drawWaveform(ctx, width, height, waveformData.peaks);
// Draw playback position
if (isPlaying && nodeData.duration) {
const progress = currentTime / nodeData.duration;
const x = progress * width;
ctx.strokeStyle = "rgba(255, 255, 255, 0.8)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
const progress = nodeData.duration && currentTime > 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

69
src/components/nodes/GenerateAudioNode.tsx

@ -26,6 +26,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
const audioRef = useRef<HTMLAudioElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const waveformContainerRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | undefined>(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<GenerateAudi
};
}, [nodeData.outputAudio]);
// Draw waveform
// Draw waveform with optional progress indicator
const drawWaveform = useCallback(
(ctx: CanvasRenderingContext2D, width: number, height: number, peaks: number[]) => {
(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<GenerateAudi
const x = i * barWidth;
const y = (height - barHeight) / 2;
ctx.fillStyle = x < progressX ? "rgb(196, 181, 253)" : "rgb(167, 139, 250)"; // violet-300 played, violet-400 unplayed
ctx.fillRect(x, y, barWidth - barGap, barHeight);
}
// Draw playback position line
if (progressX > 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<GenerateAudi
};
}, [waveformData, drawWaveform]);
// Effect: Redraw waveform with playback position
// Effect: Redraw waveform with playback progress
useEffect(() => {
if (!waveformData || !canvasRef.current) return;
@ -148,21 +159,28 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
const height = canvas.height;
if (width === 0 || height === 0) return;
drawWaveform(ctx, width, height, waveformData.peaks);
// Draw playback position
if (isPlaying && nodeData.duration) {
const progress = currentTime / nodeData.duration;
const x = progress * width;
const progress = nodeData.duration && currentTime > 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<GenerateAudi
comment={nodeData.comment}
onCustomTitleChange={(title) => 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<GenerateAudi
</div>
)}
{/* Regenerate button */}
{nodeData.status === "complete" && nodeData.outputAudio && (
<button
onClick={handleRegenerate}
disabled={isRunning}
className="w-full mt-2 px-2 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Regenerate audio"
>
Regenerate
</button>
)}
{/* Dynamic handles from schema */}
{dynamicHandles}

Loading…
Cancel
Save