@ -13,11 +13,33 @@ import { downloadMedia, MediaType } from "@/utils/downloadMedia";
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels" ;
import { HandleLabel } from "./HandleLabel" ;
import { calculateAspectFitSize , getImageDimensions , getVideoDimensions } from "@/utils/nodeDimensions" ;
import { useI18n } from "@/i18n" ;
type OutputNodeType = Node < OutputNodeData , " output " > ;
type OutputKind = "image" | "video" | "audio" | "3d" ;
interface OutputItem {
id : string ;
kind : OutputKind ;
src : string ;
label : string ;
}
function isVideoSource ( src : string ) : boolean {
return src . startsWith ( "data:video/" ) || src . includes ( ".mp4" ) || src . includes ( ".webm" ) ;
}
function isAudioSource ( src : string ) : boolean {
return src . startsWith ( "data:audio/" ) ;
}
function is3DSource ( src : string ) : boolean {
return src . includes ( ".glb" ) || src . includes ( ".gltf" ) ;
}
export function OutputNode ( { id , data , selected } : NodeProps < OutputNodeType > ) {
const nodeData = data ;
const { t } = useI18n ( ) ;
const commentNavigation = useCommentNavigation ( id ) ;
const updateNodeData = useWorkflowStore ( ( state ) = > state . updateNodeData ) ;
const regenerateNode = useWorkflowStore ( ( state ) = > state . regenerateNode ) ;
@ -28,44 +50,76 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
const isRunning = useWorkflowStore ( ( state ) = > state . isRunning ) ;
const showLabels = useShowHandleLabels ( selected ) ;
const [ showLightbox , setShowLightbox ] = useState ( false ) ;
const [ selectedItemId , setSelectedItemId ] = useState < string | null > ( null ) ;
const previousEdgeCountRef = useRef < number | null > ( null ) ;
const prevFitKeyRef = useRef < string | null > ( null ) ;
const videoAutoplayRef = useVideoAutoplay ( id , selected ) ;
// Determine if content is audio
const isAudio = useMemo ( ( ) = > {
if ( nodeData . audio ) return true ;
if ( nodeData . contentType === "audio" ) return true ;
if ( nodeData . image ? . startsWith ( "data:audio/" ) ) return true ;
return false ;
} , [ nodeData . audio , nodeData . contentType , nodeData . image ] ) ;
const is3D = useMemo ( ( ) = > {
if ( nodeData . model3d ) return true ;
if ( nodeData . contentType === "3d" ) return true ;
if ( nodeData . image ? . includes ( ".glb" ) || nodeData . image ? . includes ( ".gltf" ) ) return true ;
return false ;
} , [ nodeData . model3d , nodeData . contentType , nodeData . image ] ) ;
// Determine if content is video
const isVideo = useMemo ( ( ) = > {
if ( isAudio || is3D ) return false ;
if ( nodeData . video ) return true ;
if ( nodeData . contentType === "video" ) return true ;
if ( nodeData . image ? . startsWith ( "data:video/" ) ) return true ;
if ( nodeData . image ? . includes ( ".mp4" ) || nodeData . image ? . includes ( ".webm" ) ) return true ;
return false ;
} , [ isAudio , is3D , nodeData . video , nodeData . contentType , nodeData . image ] ) ;
// Get the content source (audio, video, 3D model, or image)
const contentSrc = useMemo ( ( ) = > {
if ( nodeData . audio ) return nodeData . audio ;
if ( nodeData . video ) return nodeData . video ;
if ( nodeData . model3d ) return nodeData . model3d ;
return nodeData . image ;
} , [ nodeData . audio , nodeData . video , nodeData . model3d , nodeData . image ] ) ;
const imageSrc = ! isAudio && ! isVideo && ! is3D ? contentSrc : null ;
const typeLabels = useMemo < Record < OutputKind , string > > ( ( ) = > ( {
image : t ( "toolbar.image" ) ,
video : t ( "toolbar.video" ) ,
audio : t ( "modelSearch.audio" ) ,
"3d" : "3D" ,
} ) , [ t ] ) ;
const outputItems = useMemo < OutputItem [ ] > ( ( ) = > {
const items : OutputItem [ ] = [ ] ;
const addItem = ( kind : OutputKind , src : string | null | undefined ) = > {
if ( ! src ) return ;
if ( items . some ( ( item ) = > item . kind === kind && item . src === src ) ) return ;
items . push ( {
id : ` ${ kind } : ${ src } ` ,
kind ,
src ,
label : typeLabels [ kind ] ,
} ) ;
} ;
const imageKind : OutputKind | null = nodeData . image
? nodeData . contentType === "video" || isVideoSource ( nodeData . image )
? "video"
: nodeData . contentType === "audio" || isAudioSource ( nodeData . image )
? "audio"
: nodeData . contentType === "3d" || is3DSource ( nodeData . image )
? "3d"
: "image"
: null ;
if ( imageKind === "image" ) addItem ( "image" , nodeData . image ) ;
addItem ( "video" , nodeData . video ? ? ( imageKind === "video" ? nodeData.image : null ) ) ;
addItem ( "audio" , nodeData . audio ? ? ( imageKind === "audio" ? nodeData.image : null ) ) ;
addItem ( "3d" , nodeData . model3d ? ? ( imageKind === "3d" ? nodeData.image : null ) ) ;
return items ;
} , [ nodeData . audio , nodeData . contentType , nodeData . image , nodeData . model3d , nodeData . video , typeLabels ] ) ;
const preferredItemId = useMemo ( ( ) = > {
if ( outputItems . length === 0 ) return null ;
const explicitItem = nodeData . contentType
? outputItems . find ( ( item ) = > item . kind === nodeData . contentType )
: null ;
if ( explicitItem ) return explicitItem . id ;
const legacyPriority : OutputKind [ ] = [ "audio" , "video" , "3d" , "image" ] ;
return legacyPriority
. map ( ( kind ) = > outputItems . find ( ( item ) = > item . kind === kind ) )
. find ( Boolean ) ? . id ? ? outputItems [ 0 ] . id ;
} , [ nodeData . contentType , outputItems ] ) ;
const activeItem = useMemo ( ( ) = > {
if ( outputItems . length === 0 ) return null ;
return outputItems . find ( ( item ) = > item . id === selectedItemId )
? ? outputItems . find ( ( item ) = > item . id === preferredItemId )
? ? outputItems [ 0 ] ;
} , [ outputItems , preferredItemId , selectedItemId ] ) ;
const contentSrc = activeItem ? . src ? ? null ;
const isAudio = activeItem ? . kind === "audio" ;
const isVideo = activeItem ? . kind === "video" ;
const is3D = activeItem ? . kind === "3d" ;
const imageSrc = activeItem ? . kind === "image" ? contentSrc : null ;
const adaptiveImage = useAdaptiveImageSrc ( imageSrc , id ) ;
const videoBlobUrl = useVideoBlobUrl ( isVideo ? contentSrc ? ? null : null ) ;
@ -137,14 +191,32 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
} , [ id , regenerateNode ] ) ;
const handleDownload = useCallback ( async ( ) = > {
if ( ! contentSrc ) return ;
const type : MediaType = is3D ? "3d" : isAudio ? "audio" : isVideo ? "video" : "image" ;
if ( ! contentSrc || ! activeItem ) return ;
const type : MediaType = activeItem . kind ;
try {
await downloadMedia ( contentSrc , type , nodeData . outputFilename ? ? undefined ) ;
} catch ( err ) {
console . error ( "Download failed:" , err ) ;
}
} , [ contentSrc , is3D , isAudio , isVideo , nodeData . outputFilename ] ) ;
} , [ activeItem , contentSrc , nodeData . outputFilename ] ) ;
const handleDownloadAll = useCallback ( async ( ) = > {
for ( const item of outputItems ) {
const filename = nodeData . outputFilename
? ` ${ nodeData . outputFilename } - ${ item . kind } `
: undefined ;
try {
await downloadMedia ( item . src , item . kind , filename ) ;
} catch ( err ) {
console . error ( "Download failed:" , err ) ;
}
}
} , [ nodeData . outputFilename , outputItems ] ) ;
const downloadCurrentTitle = outputItems . length > 1 && activeItem
? t ( "output.downloadCurrent" , { type : activeItem . label } )
: t ( "common.download" ) ;
const downloadAllTitle = t ( "output.downloadAll" ) ;
return (
< >
@ -163,7 +235,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
data - handletype = "image"
style = { { top : "28%" , zIndex : 10 } }
/ >
< HandleLabel label = "Image" side = "target" color = "var(--handle-color-image)" top = "calc(28% - 18px)" visible = { showLabels } / >
< HandleLabel label = { typeLabels . image } side = "target" color = "var(--handle-color-image)" top = "calc(28% - 18px)" visible = { showLabels } / >
< Handle
type = "target"
position = { Position . Left }
@ -171,7 +243,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
data - handletype = "video"
style = { { top : "44%" , zIndex : 10 } }
/ >
< HandleLabel label = "Video" side = "target" color = "var(--handle-color-video)" top = "calc(44% - 18px)" visible = { showLabels } / >
< HandleLabel label = { typeLabels . video } side = "target" color = "var(--handle-color-video)" top = "calc(44% - 18px)" visible = { showLabels } / >
< Handle
type = "target"
position = { Position . Left }
@ -179,7 +251,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
data - handletype = "audio"
style = { { top : "60%" , background : "rgb(167, 139, 250)" , zIndex : 10 } }
/ >
< HandleLabel label = "Audio" side = "target" color = "var(--handle-color-audio)" top = "calc(60% - 18px)" visible = { showLabels } / >
< HandleLabel label = { typeLabels . audio } side = "target" color = "var(--handle-color-audio)" top = "calc(60% - 18px)" visible = { showLabels } / >
< Handle
type = "target"
position = { Position . Left }
@ -190,6 +262,30 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
< HandleLabel label = "3D" side = "target" color = "var(--handle-color-3d)" top = "calc(76% - 18px)" visible = { showLabels } / >
< div className = "relative w-full h-full overflow-hidden rounded-lg" >
{ outputItems . length > 1 && (
< div className = "absolute left-2 top-2 z-20 flex max-w-[calc(100%-5.5rem)] gap-1 overflow-x-auto rounded bg-black/50 p-1 backdrop-blur-sm" >
{ outputItems . map ( ( item ) = > {
const isActive = activeItem ? . id === item . id ;
return (
< button
key = { item . id }
type = "button"
data - testid = { ` output-tab- ${ item . kind } ` }
onClick = { ( ) = > setSelectedItemId ( item . id ) }
aria - pressed = { isActive }
className = { ` shrink-0 rounded px-2 py-0.5 text-[10px] font-medium transition-colors ${
isActive
? "bg-white text-neutral-900"
: "text-neutral-300 hover:bg-white/10 hover:text-white"
} ` }
title = { item . label }
>
{ item . label } 1
< / button >
) ;
} ) }
< / div >
) }
{ contentSrc ? (
< >
{ isAudio ? (
@ -240,10 +336,21 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
< / div >
< / div >
) }
{ outputItems . length > 1 && (
< button
onClick = { handleDownloadAll }
className = "absolute top-2 right-10 p-1.5 bg-black/60 hover:bg-black/80 text-white text-xs rounded transition-colors flex items-center gap-1"
title = { downloadAllTitle }
>
< 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 = "M7 11l5 5 5-5M12 16V4M5 20h14" / >
< / svg >
< / button >
) }
< button
onClick = { handleDownload }
className = "absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white text-xs rounded transition-colors flex items-center gap-1"
title = "Download"
title = { downloadCurrentTitle }
>
< 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" / >