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. 9
      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 { useWorkflowStore } from "@/store/workflowStore";
import { AnnotationNodeData } from "@/types"; import { AnnotationNodeData } from "@/types";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia } from "@/utils/downloadMedia";
type AnnotationNodeType = Node<AnnotationNodeData, "annotation">; type AnnotationNodeType = Node<AnnotationNodeData, "annotation">;
@ -130,6 +131,17 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
alt="Annotated" alt="Annotated"
className="w-full h-full object-contain" 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 <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

11
src/components/nodes/AudioInputNode.tsx

@ -7,6 +7,7 @@ import { useWorkflowStore } from "@/store/workflowStore";
import { AudioInputNodeData } from "@/types"; import { AudioInputNodeData } from "@/types";
import { useAudioVisualization } from "@/hooks/useAudioVisualization"; import { useAudioVisualization } from "@/hooks/useAudioVisualization";
import { useAudioPlayback } from "@/hooks/useAudioPlayback"; import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { downloadMedia } from "@/utils/downloadMedia";
type AudioInputNodeType = Node<AudioInputNodeData, "audioInput">; type AudioInputNodeType = Node<AudioInputNodeData, "audioInput">;
@ -213,6 +214,16 @@ export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeT
</span> </span>
</div> </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 */} {/* Remove button */}
<button <button
onClick={handleRemove} onClick={handleRemove}

9
src/components/nodes/GLBViewerNode.tsx

@ -9,6 +9,7 @@ import { useWorkflowStore } from "@/store/workflowStore";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { GLBViewerNodeData } from "@/types"; import { GLBViewerNodeData } from "@/types";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia } from "@/utils/downloadMedia";
import * as THREE from "three"; import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
@ -472,6 +473,13 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
<div className="w-1.5 h-1.5 rounded-full bg-green-500" /> <div className="w-1.5 h-1.5 rounded-full bg-green-500" />
Captured Captured
</span> </span>
<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 <button
onClick={() => updateNodeData(id, { capturedImage: null })} onClick={() => updateNodeData(id, { capturedImage: null })}
className="text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors" className="text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors"
@ -479,6 +487,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
Clear Clear
</button> </button>
</div> </div>
</div>
<img <img
src={adaptiveCapturedImage ?? undefined} src={adaptiveCapturedImage ?? undefined}
alt="Captured 3D render" alt="Captured 3D render"

11
src/components/nodes/GenerateAudioNode.tsx

@ -15,6 +15,7 @@ import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel"; import { InlineParameterPanel } from "./InlineParameterPanel";
import { SettingsTabBar } from "./SettingsTabBar"; import { SettingsTabBar } from "./SettingsTabBar";
import { browseRegistry } from "@/utils/browseRegistry"; import { browseRegistry } from "@/utils/browseRegistry";
import { downloadMedia } from "@/utils/downloadMedia";
type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">; type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">;
@ -414,6 +415,16 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
)} )}
</div> </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 */} {/* Clear button */}
<button <button
onClick={handleClearAudio} onClick={handleClearAudio}

14
src/components/nodes/GenerateImageNode.tsx

@ -17,6 +17,7 @@ import { InlineParameterPanel } from "./InlineParameterPanel";
import { SettingsTabBar } from "./SettingsTabBar"; import { SettingsTabBar } from "./SettingsTabBar";
import { browseRegistry } from "@/utils/browseRegistry"; import { browseRegistry } from "@/utils/browseRegistry";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia } from "@/utils/downloadMedia";
/** Reorder items so they read column-first in a row-based CSS grid. /** 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] */ * 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> </svg>
</div> </div>
)} )}
{/* Clear button */} {/* Download + Clear buttons */}
<div className="absolute top-1 right-1"> <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 <button
onClick={handleClearImage} 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" 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 { InlineParameterPanel } from "./InlineParameterPanel";
import { SettingsTabBar } from "./SettingsTabBar"; import { SettingsTabBar } from "./SettingsTabBar";
import { browseRegistry } from "@/utils/browseRegistry"; import { browseRegistry } from "@/utils/browseRegistry";
import { downloadMedia } from "@/utils/downloadMedia";
// Video generation capabilities // Video generation capabilities
const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video", "audio-to-video"]; 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> </svg>
</div> </div>
)} )}
{/* Clear button */} {/* Download + Clear buttons */}
<div className="absolute top-1 right-1"> <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 <button
onClick={handleClearVideo} 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" 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 { useWorkflowStore } from "@/store/workflowStore";
import { ImageInputNodeData } from "@/types"; import { ImageInputNodeData } from "@/types";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia } from "@/utils/downloadMedia";
type ImageInputNodeType = Node<ImageInputNodeData, "imageInput">; type ImageInputNodeType = Node<ImageInputNodeData, "imageInput">;
@ -111,6 +112,15 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
Optional Optional
</span> </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 <button
onClick={handleRemove} onClick={handleRemove}
aria-label="Remove image" aria-label="Remove image"

38
src/components/nodes/OutputNode.tsx

@ -9,6 +9,7 @@ import { OutputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { useVideoAutoplay } from "@/hooks/useVideoAutoplay"; import { useVideoAutoplay } from "@/hooks/useVideoAutoplay";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { downloadMedia, MediaType } from "@/utils/downloadMedia";
type OutputNodeType = Node<OutputNodeData, "output">; type OutputNodeType = Node<OutputNodeData, "output">;
@ -74,41 +75,8 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const handleDownload = useCallback(async () => { const handleDownload = useCallback(async () => {
if (!contentSrc) return; if (!contentSrc) return;
const type: MediaType = isAudio ? "audio" : isVideo ? "video" : "image";
const timestamp = Date.now(); await downloadMedia(contentSrc, type, nodeData.outputFilename ?? undefined);
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);
}, [contentSrc, isAudio, isVideo, nodeData.outputFilename]); }, [contentSrc, isAudio, isVideo, nodeData.outputFilename]);
return ( return (

10
src/components/nodes/VideoInputNode.tsx

@ -6,6 +6,7 @@ import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { VideoInputNodeData } from "@/types"; import { VideoInputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { downloadMedia } from "@/utils/downloadMedia";
type VideoInputNodeType = Node<VideoInputNodeData, "videoInput">; type VideoInputNodeType = Node<VideoInputNodeData, "videoInput">;
@ -138,6 +139,15 @@ export function VideoInputNode({ id, data, selected }: NodeProps<VideoInputNodeT
Optional Optional
</span> </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 <button
onClick={handleRemove} onClick={handleRemove}
aria-label="Remove video" 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