From 5e9035bed1297b6c968b021905a58d953af4f724 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Thu, 19 Feb 2026 11:56:52 +1300 Subject: [PATCH] feat(quick-007): add VideoTrimNode UI component - Dual range sliders with visual timeline scrubber for start/end times - Inline video preview with play/pause controls (shows source or trimmed) - Source/Trimmed toggle when output is available - Time labels: start, duration, end (M:SS.s format) - Trim button (disabled when no source, loading, or invalid range) - Processing overlay with progress percentage - Error display for processing failures - Encoder check states (not supported / checking) - Auto-detects video duration on connect and sets endTime --- src/components/nodes/VideoTrimNode.tsx | 429 +++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 src/components/nodes/VideoTrimNode.tsx diff --git a/src/components/nodes/VideoTrimNode.tsx b/src/components/nodes/VideoTrimNode.tsx new file mode 100644 index 00000000..af477aef --- /dev/null +++ b/src/components/nodes/VideoTrimNode.tsx @@ -0,0 +1,429 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { BaseNode } from "./BaseNode"; +import { useCommentNavigation } from "@/hooks/useCommentNavigation"; +import { useWorkflowStore } from "@/store/workflowStore"; +import { VideoTrimNodeData } from "@/types"; +import { checkEncoderSupport } from "@/hooks/useStitchVideos"; + +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 commentNavigation = useCommentNavigation(id); + 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); + + // Ref for hidden video element used to detect source duration + const durationVideoRef = useRef(null); + const prevSourceVideoRef = useRef(null); + + // 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 || sourceVideoUrl === prevSourceVideoRef.current) return; + prevSourceVideoRef.current = sourceVideoUrl; + + const video = document.createElement("video"); + video.preload = "metadata"; + video.onloadedmetadata = () => { + 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: nodeData.endTime === 0 ? dur : nodeData.endTime, + }); + } + // Cleanup + const blobWasCreated = video.src.startsWith("blob:"); + if (blobWasCreated) { + URL.revokeObjectURL(video.src); + } + }; + video.onerror = () => { + if (video.src.startsWith("blob:")) { + URL.revokeObjectURL(video.src); + } + }; + + // If the source is a data URL, create a blob URL for metadata loading efficiency + if (sourceVideoUrl.startsWith("data:")) { + fetch(sourceVideoUrl) + .then((r) => r.blob()) + .then((blob) => { + video.src = URL.createObjectURL(blob); + }) + .catch(() => { + video.src = sourceVideoUrl; + }); + } else { + video.src = sourceVideoUrl; + } + }, [sourceVideoUrl, id, updateNodeData, nodeData.endTime]); + + // 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; + + // 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 ( + updateNodeData(id, { customTitle: title || undefined })} + onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + selected={selected} + commentNavigation={commentNavigation ?? undefined} + minWidth={360} + minHeight={360} + > + {renderHandles()} +
+ + + + + Your browser doesn't support video encoding. + + + Doesn't seem right? Message Willie on Discord. + +
+
+ ); + } + + // Checking encoder state + if (nodeData.encoderSupported === null) { + return ( + updateNodeData(id, { customTitle: title || undefined })} + onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + selected={selected} + commentNavigation={commentNavigation ?? undefined} + minWidth={360} + minHeight={360} + > + {renderHandles()} +
+
+ + + + + Checking encoder... +
+
+
+ ); + } + + return ( + updateNodeData(id, { customTitle: title || undefined })} + onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + onRun={canTrim ? handleTrim : undefined} + selected={selected} + isExecuting={isRunning} + hasError={nodeData.status === "error"} + commentNavigation={commentNavigation ?? undefined} + minWidth={360} + minHeight={360} + > + {renderHandles()} + +
+ {/* Video preview area */} +
+ {previewUrl ? ( +
+ + {/* Trim controls (only shown when we have a duration) */} + {hasSourceVideo && ( +
+ {/* Dual range slider */} +
+ {/* Track background */} +
+ + {/* Highlighted trim range */} +
+ + {/* Start slider */} + 0 ? duration : 100} + step={0.1} + value={startTime} + onChange={handleStartChange} + className="absolute w-full h-full opacity-0 cursor-pointer nodrag" + style={{ zIndex: 2 }} + /> + + {/* End slider */} + 0 ? duration : 100} + step={0.1} + value={endTime} + onChange={handleEndChange} + className="absolute w-full h-full opacity-0 cursor-pointer nodrag" + style={{ zIndex: 3 }} + /> + + {/* 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}

+
+ )} +
+ + ); +}