Browse Source

feat: add download buttons to all media-displaying nodes

Extract shared downloadMedia utility from OutputNode and add download
buttons to ImageInput, VideoInput, Annotation, AudioInput, GenerateImage,
GenerateVideo, GenerateAudio, and GLBViewer nodes. Refactor OutputNode to
use the shared utility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
9230d4e3eb
  1. 12
      src/components/nodes/AnnotationNode.tsx
  2. 11
      src/components/nodes/AudioInputNode.tsx
  3. 21
      src/components/nodes/GLBViewerNode.tsx
  4. 11
      src/components/nodes/GenerateAudioNode.tsx
  5. 14
      src/components/nodes/GenerateImageNode.tsx
  6. 14
      src/components/nodes/GenerateVideoNode.tsx
  7. 10
      src/components/nodes/ImageInputNode.tsx
  8. 38
      src/components/nodes/OutputNode.tsx
  9. 10
      src/components/nodes/VideoInputNode.tsx
  10. 94
      src/utils/downloadMedia.ts

12
src/components/nodes/AnnotationNode.tsx

@ -7,6 +7,7 @@ import { useAnnotationStore } from "@/store/annotationStore";
import { useWorkflowStore } from "@/store/workflowStore";
import { AnnotationNodeData } from "@/types";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia } from "@/utils/downloadMedia";
type AnnotationNodeType = Node<AnnotationNodeData, "annotation">;
@ -130,6 +131,17 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
alt="Annotated"
className="w-full h-full object-contain"
/>
<button
onClick={(e) => {
e.stopPropagation();
downloadMedia(displayImage!, "image");
}}
className="absolute top-2 right-10 w-6 h-6 bg-black/60 hover:bg-black/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();

11
src/components/nodes/AudioInputNode.tsx

@ -7,6 +7,7 @@ import { useWorkflowStore } from "@/store/workflowStore";
import { AudioInputNodeData } from "@/types";
import { useAudioVisualization } from "@/hooks/useAudioVisualization";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { downloadMedia } from "@/utils/downloadMedia";
type AudioInputNodeType = Node<AudioInputNodeData, "audioInput">;
@ -213,6 +214,16 @@ export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeT
</span>
</div>
{/* Download button */}
<button
onClick={() => downloadMedia(nodeData.audioFile!, "audio")}
className="absolute top-1 right-7 w-5 h-5 bg-black/60 hover:bg-black/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Download audio"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
{/* Remove button */}
<button
onClick={handleRemove}

21
src/components/nodes/GLBViewerNode.tsx

@ -9,6 +9,7 @@ import { useWorkflowStore } from "@/store/workflowStore";
import { useToast } from "@/components/Toast";
import { GLBViewerNodeData } from "@/types";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia } from "@/utils/downloadMedia";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
@ -472,12 +473,20 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Captured
</span>
<button
onClick={() => updateNodeData(id, { capturedImage: null })}
className="text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
Clear
</button>
<div className="flex items-center gap-2">
<button
onClick={() => downloadMedia(nodeData.capturedImage!, "image")}
className="text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
Download
</button>
<button
onClick={() => updateNodeData(id, { capturedImage: null })}
className="text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
Clear
</button>
</div>
</div>
<img
src={adaptiveCapturedImage ?? undefined}

11
src/components/nodes/GenerateAudioNode.tsx

@ -15,6 +15,7 @@ import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel";
import { SettingsTabBar } from "./SettingsTabBar";
import { browseRegistry } from "@/utils/browseRegistry";
import { downloadMedia } from "@/utils/downloadMedia";
type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">;
@ -414,6 +415,16 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
)}
</div>
{/* Download button */}
<button
onClick={() => downloadMedia(nodeData.outputAudio!, "audio")}
className="absolute top-1 right-7 w-5 h-5 bg-black/60 hover:bg-black/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Download audio"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
{/* Clear button */}
<button
onClick={handleClearAudio}

14
src/components/nodes/GenerateImageNode.tsx

@ -17,6 +17,7 @@ import { InlineParameterPanel } from "./InlineParameterPanel";
import { SettingsTabBar } from "./SettingsTabBar";
import { browseRegistry } from "@/utils/browseRegistry";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia } from "@/utils/downloadMedia";
/** Reorder items so they read column-first in a row-based CSS grid.
* e.g. [1,2,3,4,5,6,7,8] with 2 cols [1,5,2,6,3,7,4,8] */
@ -805,8 +806,17 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
</svg>
</div>
)}
{/* Clear button */}
<div className="absolute top-1 right-1">
{/* Download + Clear buttons */}
<div className="absolute top-1 right-1 flex items-center gap-0.5">
<button
onClick={() => downloadMedia(nodeData.outputImage!, "image")}
className="w-5 h-5 bg-neutral-900/80 hover:bg-neutral-700 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Download image"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
<button
onClick={handleClearImage}
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"

14
src/components/nodes/GenerateVideoNode.tsx

@ -18,6 +18,7 @@ import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel";
import { SettingsTabBar } from "./SettingsTabBar";
import { browseRegistry } from "@/utils/browseRegistry";
import { downloadMedia } from "@/utils/downloadMedia";
// Video generation capabilities
const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video", "audio-to-video"];
@ -769,8 +770,17 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
</svg>
</div>
)}
{/* Clear button */}
<div className="absolute top-1 right-1">
{/* Download + Clear buttons */}
<div className="absolute top-1 right-1 flex items-center gap-0.5">
<button
onClick={() => downloadMedia(nodeData.outputVideo!, "video")}
className="w-5 h-5 bg-neutral-900/80 hover:bg-neutral-700 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Download video"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
<button
onClick={handleClearVideo}
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"

10
src/components/nodes/ImageInputNode.tsx

@ -7,6 +7,7 @@ import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore";
import { ImageInputNodeData } from "@/types";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia } from "@/utils/downloadMedia";
type ImageInputNodeType = Node<ImageInputNodeData, "imageInput">;
@ -111,6 +112,15 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
Optional
</span>
)}
<button
onClick={() => downloadMedia(nodeData.image!, "image")}
aria-label="Download image"
className="absolute top-2 right-10 w-6 h-6 bg-black/60 hover:bg-black/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all flex items-center justify-center"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
<button
onClick={handleRemove}
aria-label="Remove image"

38
src/components/nodes/OutputNode.tsx

@ -9,6 +9,7 @@ import { OutputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { useVideoAutoplay } from "@/hooks/useVideoAutoplay";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia, MediaType } from "@/utils/downloadMedia";
type OutputNodeType = Node<OutputNodeData, "output">;
@ -74,41 +75,8 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const handleDownload = useCallback(async () => {
if (!contentSrc) return;
const timestamp = Date.now();
const extension = isAudio ? "mp3" : isVideo ? "mp4" : "png";
// Use custom filename if provided, otherwise use timestamp
const filename = nodeData.outputFilename
? `${nodeData.outputFilename}.${extension}`
: `generated-${timestamp}.${extension}`;
// Handle URL-based content (needs fetch + blob conversion)
if (contentSrc.startsWith("http://") || contentSrc.startsWith("https://")) {
try {
const response = await fetch(contentSrc);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error("Failed to download:", error);
}
return;
}
// Handle data URL content (direct download)
const link = document.createElement("a");
link.href = contentSrc;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const type: MediaType = isAudio ? "audio" : isVideo ? "video" : "image";
await downloadMedia(contentSrc, type, nodeData.outputFilename ?? undefined);
}, [contentSrc, isAudio, isVideo, nodeData.outputFilename]);
return (

10
src/components/nodes/VideoInputNode.tsx

@ -6,6 +6,7 @@ import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { VideoInputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { downloadMedia } from "@/utils/downloadMedia";
type VideoInputNodeType = Node<VideoInputNodeData, "videoInput">;
@ -138,6 +139,15 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
Optional
</span>
)}
<button
onClick={() => downloadMedia(nodeData.video!, "video")}
aria-label="Download video"
className="absolute top-2 right-10 w-6 h-6 bg-black/60 hover:bg-black/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all flex items-center justify-center"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
<button
onClick={handleRemove}
aria-label="Remove video"

94
src/utils/downloadMedia.ts

@ -0,0 +1,94 @@
/**
* Shared utility for downloading media (images, video, audio) from nodes.
* Handles both data URLs (base64) and HTTP URLs.
*/
/** Infer a file extension from a data URL's MIME type. */
function extensionFromDataUrl(dataUrl: string): string {
const match = dataUrl.match(/^data:([^;,]+)/);
if (!match) return "bin";
const mime = match[1];
const map: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/wav": "wav",
"audio/ogg": "ogg",
"audio/aac": "aac",
"audio/mp4": "m4a",
};
return map[mime] ?? mime.split("/")[1] ?? "bin";
}
/** Infer a file extension from a URL path. */
function extensionFromUrl(url: string): string {
try {
const pathname = new URL(url).pathname;
const ext = pathname.split(".").pop()?.toLowerCase();
if (ext && ext.length <= 5 && ext.length >= 2) return ext;
} catch {
// not a valid URL
}
return "bin";
}
export type MediaType = "image" | "video" | "audio";
/**
* Download media content via an anchor-click approach.
*
* @param src - Data URL (base64) or HTTP URL of the media
* @param mediaType - Hint for file extension when MIME detection fails
* @param filename - Optional custom filename (without extension)
*/
export async function downloadMedia(
src: string,
mediaType: MediaType = "image",
filename?: string,
): Promise<void> {
const fallbackExt: Record<MediaType, string> = {
image: "png",
video: "mp4",
audio: "mp3",
};
const isHttp = src.startsWith("http://") || src.startsWith("https://");
const ext = isHttp
? extensionFromUrl(src)
: extensionFromDataUrl(src);
const finalExt = ext === "bin" ? fallbackExt[mediaType] : ext;
const finalName = filename
? `${filename}.${finalExt}`
: `${mediaType}-${Date.now()}.${finalExt}`;
if (isHttp) {
try {
const response = await fetch(src);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
triggerDownload(blobUrl, finalName);
URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error("Failed to download:", error);
}
return;
}
// Data URL — direct download
triggerDownload(src, finalName);
}
function triggerDownload(href: string, filename: string): void {
const link = document.createElement("a");
link.href = href;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
Loading…
Cancel
Save