"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { VideoTrimNodeData } from "@/types"; import { checkEncoderSupport } from "@/hooks/useStitchVideos"; import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; import { useVideoAutoplay } from "@/hooks/useVideoAutoplay"; type VideoTrimNodeType = Node; /** * Format a time value in seconds as M:SS.s (e.g. "0:02.5") */ function formatTime(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toFixed(1).padStart(4, "0")}`; } export function VideoTrimNode({ id, data, selected }: NodeProps) { const nodeData = data; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); const edges = useWorkflowStore((state) => state.edges); const nodes = useWorkflowStore((state) => state.nodes); // Track whether user wants to see source or output video const [showOutput, setShowOutput] = useState(false); const videoAutoplayRef = useVideoAutoplay(id, selected); // Keep a ref to endTime so the metadata callback reads fresh state const endTimeRef = useRef(nodeData.endTime); endTimeRef.current = nodeData.endTime; // Check encoder support on mount useEffect(() => { if (nodeData.encoderSupported === null) { checkEncoderSupport().then((supported) => { updateNodeData(id, { encoderSupported: supported }); }); } }, [id, nodeData.encoderSupported, updateNodeData]); // Find connected source video from incoming edges const sourceVideoUrl = useMemo(() => { const incomingEdge = edges.find((e) => e.target === id && e.targetHandle === "video"); if (!incomingEdge) return null; const sourceNode = nodes.find((n) => n.id === incomingEdge.source); if (!sourceNode) return null; const d = sourceNode.data as Record; // Support common video output fields from generateVideo, videoStitch, easeCurve, videoTrim return (d.outputVideo as string | null) ?? null; }, [edges, nodes, id]); // When source video changes, load metadata to detect duration useEffect(() => { if (!sourceVideoUrl) return; let cancelled = false; const abortController = new AbortController(); let blobUrl: string | null = null; const video = document.createElement("video"); video.preload = "metadata"; video.onloadedmetadata = () => { if (cancelled) return; const dur = video.duration; if (Number.isFinite(dur) && dur > 0) { updateNodeData(id, { duration: dur, // Only auto-set endTime if it hasn't been set yet (still at default 0) endTime: endTimeRef.current === 0 ? dur : endTimeRef.current, }); } if (blobUrl) URL.revokeObjectURL(blobUrl); blobUrl = null; }; video.onerror = () => { if (cancelled) return; if (blobUrl) URL.revokeObjectURL(blobUrl); blobUrl = null; }; // If the source is a data URL, create a blob URL for metadata loading efficiency if (sourceVideoUrl.startsWith("data:")) { fetch(sourceVideoUrl, { signal: abortController.signal }) .then((r) => r.blob()) .then((blob) => { if (cancelled) return; blobUrl = URL.createObjectURL(blob); video.src = blobUrl; }) .catch(() => { if (cancelled) return; video.src = sourceVideoUrl; }); } else { video.src = sourceVideoUrl; } return () => { cancelled = true; abortController.abort(); video.onloadedmetadata = null; video.onerror = null; video.src = ""; if (blobUrl) URL.revokeObjectURL(blobUrl); }; }, [sourceVideoUrl, id, updateNodeData]); // Auto-switch to output when trimming completes const prevOutputVideoRef = useRef(nodeData.outputVideo); useEffect(() => { if (!prevOutputVideoRef.current && nodeData.outputVideo) { setShowOutput(true); } prevOutputVideoRef.current = nodeData.outputVideo; }, [nodeData.outputVideo]); const duration = nodeData.duration ?? 0; const startTime = nodeData.startTime; const endTime = nodeData.endTime > 0 ? nodeData.endTime : duration; const trimDuration = Math.max(0, endTime - startTime); const handleStartChange = useCallback( (e: React.ChangeEvent) => { const val = parseFloat(e.target.value); const clamped = Math.min(val, endTime - 0.1); updateNodeData(id, { startTime: Math.max(0, clamped) }); }, [id, updateNodeData, endTime] ); const handleEndChange = useCallback( (e: React.ChangeEvent) => { const val = parseFloat(e.target.value); const clamped = Math.max(val, startTime + 0.1); updateNodeData(id, { endTime: Math.min(duration > 0 ? duration : clamped, clamped) }); }, [id, updateNodeData, startTime, duration] ); const handleTrim = useCallback(() => { regenerateNode(id); }, [id, regenerateNode]); const hasSourceVideo = Boolean(sourceVideoUrl); const canTrim = hasSourceVideo && startTime < endTime && endTime > 0; // Which video URL to show in preview const previewUrl = showOutput && nodeData.outputVideo ? nodeData.outputVideo : sourceVideoUrl; const previewBlobUrl = useVideoBlobUrl(previewUrl); // Compute slider thumb position percentages for the visual range highlight const startPct = duration > 0 ? (startTime / duration) * 100 : 0; const endPct = duration > 0 ? (endTime / duration) * 100 : 100; // Shared handles rendered in ALL states const renderHandles = () => ( <> {/* Video In (target, left, 50%) */}
Video In
{/* Video Out (source, right, 50%) */}
Video Out
); // Encoder not supported if (nodeData.encoderSupported === false) { return ( {renderHandles()}
Your browser doesn't support video encoding. Doesn't seem right? Message Willie on Discord.
); } // Checking encoder state if (nodeData.encoderSupported === null) { return ( {renderHandles()}
Checking encoder...
); } return ( {renderHandles()}
{/* Video preview area */}
{previewUrl ? (
{/* Trim controls (only shown when we have a duration) */} {hasSourceVideo && (
{/* Dual range slider */}
{/* Make only the slider thumbs interactive, not the full-width invisible input bodies */} {/* Track background */}
{/* Highlighted trim range */}
{/* Start slider (higher z-index so it's draggable when both thumbs overlap at 0) */} 0 ? duration : 100} step={0.1} value={startTime} onChange={handleStartChange} className="absolute w-full h-full opacity-0 nodrag" style={{ zIndex: 3 }} /> {/* End slider */} 0 ? duration : 100} step={0.1} value={endTime} onChange={handleEndChange} className="absolute w-full h-full opacity-0 nodrag" style={{ zIndex: 2 }} /> {/* Visual thumb indicators */}
{/* Time labels */}
Start {formatTime(startTime)}
Duration {formatTime(trimDuration)}
End {formatTime(endTime)}
)} {/* Trim button */}
{/* Processing overlay */} {nodeData.status === "loading" && (
Processing... {Math.round(nodeData.progress)}%
)} {/* Error display */} {nodeData.status === "error" && nodeData.error && (

{nodeData.error}

)}
); }