Browse Source
- 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 endTimehandoff-20260429-1057
1 changed files with 429 additions and 0 deletions
@ -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<VideoTrimNodeData, "videoTrim">; |
||||
|
|
||||
|
/** |
||||
|
* 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<VideoTrimNodeType>) { |
||||
|
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<HTMLVideoElement>(null); |
||||
|
const prevSourceVideoRef = useRef<string | null>(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<string, unknown>; |
||||
|
// 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<HTMLInputElement>) => { |
||||
|
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<HTMLInputElement>) => { |
||||
|
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%) */} |
||||
|
<Handle |
||||
|
type="target" |
||||
|
position={Position.Left} |
||||
|
id="video" |
||||
|
data-handletype="video" |
||||
|
isConnectable={true} |
||||
|
style={{ top: "50%" }} |
||||
|
/> |
||||
|
<div |
||||
|
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right" |
||||
|
style={{ right: "calc(100% + 8px)", top: "calc(50% - 7px)", color: "rgb(168, 85, 247)" }} |
||||
|
> |
||||
|
Video In |
||||
|
</div> |
||||
|
|
||||
|
{/* Video Out (source, right, 50%) */} |
||||
|
<Handle |
||||
|
type="source" |
||||
|
position={Position.Right} |
||||
|
id="video" |
||||
|
data-handletype="video" |
||||
|
isConnectable={true} |
||||
|
style={{ top: "50%" }} |
||||
|
/> |
||||
|
<div |
||||
|
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none" |
||||
|
style={{ left: "calc(100% + 8px)", top: "calc(50% - 7px)", color: "rgb(168, 85, 247)" }} |
||||
|
> |
||||
|
Video Out |
||||
|
</div> |
||||
|
</> |
||||
|
); |
||||
|
|
||||
|
// Encoder not supported
|
||||
|
if (nodeData.encoderSupported === false) { |
||||
|
return ( |
||||
|
<BaseNode |
||||
|
id={id} |
||||
|
title="Video Trim" |
||||
|
customTitle={nodeData.customTitle} |
||||
|
comment={nodeData.comment} |
||||
|
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })} |
||||
|
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} |
||||
|
selected={selected} |
||||
|
commentNavigation={commentNavigation ?? undefined} |
||||
|
minWidth={360} |
||||
|
minHeight={360} |
||||
|
> |
||||
|
{renderHandles()} |
||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-center px-4"> |
||||
|
<svg className="w-8 h-8 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> |
||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> |
||||
|
</svg> |
||||
|
<span className="text-xs text-neutral-400"> |
||||
|
Your browser doesn't support video encoding. |
||||
|
</span> |
||||
|
<a |
||||
|
href="https://discord.gg/placeholder" |
||||
|
target="_blank" |
||||
|
rel="noopener noreferrer" |
||||
|
className="text-[10px] text-blue-400 hover:text-blue-300 underline" |
||||
|
> |
||||
|
Doesn't seem right? Message Willie on Discord. |
||||
|
</a> |
||||
|
</div> |
||||
|
</BaseNode> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Checking encoder state
|
||||
|
if (nodeData.encoderSupported === null) { |
||||
|
return ( |
||||
|
<BaseNode |
||||
|
id={id} |
||||
|
title="Video Trim" |
||||
|
customTitle={nodeData.customTitle} |
||||
|
comment={nodeData.comment} |
||||
|
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })} |
||||
|
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} |
||||
|
selected={selected} |
||||
|
commentNavigation={commentNavigation ?? undefined} |
||||
|
minWidth={360} |
||||
|
minHeight={360} |
||||
|
> |
||||
|
{renderHandles()} |
||||
|
<div className="flex-1 flex items-center justify-center"> |
||||
|
<div className="flex items-center gap-2 text-neutral-400"> |
||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"> |
||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" /> |
||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> |
||||
|
</svg> |
||||
|
<span className="text-xs">Checking encoder...</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</BaseNode> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<BaseNode |
||||
|
id={id} |
||||
|
title="Video Trim" |
||||
|
customTitle={nodeData.customTitle} |
||||
|
comment={nodeData.comment} |
||||
|
onCustomTitleChange={(title) => 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()} |
||||
|
|
||||
|
<div className="flex-1 flex flex-col min-h-0 gap-2"> |
||||
|
{/* Video preview area */} |
||||
|
<div className="flex-1 min-h-0 relative"> |
||||
|
{previewUrl ? ( |
||||
|
<video |
||||
|
key={previewUrl} |
||||
|
src={previewUrl} |
||||
|
controls |
||||
|
playsInline |
||||
|
muted |
||||
|
loop |
||||
|
className="absolute inset-0 w-full h-full object-contain rounded" |
||||
|
/> |
||||
|
) : ( |
||||
|
<div className="absolute inset-0 flex items-center justify-center border border-dashed border-neutral-600 rounded"> |
||||
|
<span className="text-[10px] text-neutral-500">Connect a video to trim</span> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Source / trimmed toggle (shown when output exists) */} |
||||
|
{nodeData.outputVideo && sourceVideoUrl && ( |
||||
|
<div className="absolute top-1 left-1 flex gap-1"> |
||||
|
<button |
||||
|
onClick={() => setShowOutput(false)} |
||||
|
className={`px-1.5 py-0.5 rounded text-[9px] font-medium transition-colors ${ |
||||
|
!showOutput |
||||
|
? "bg-neutral-700 text-neutral-200" |
||||
|
: "bg-neutral-900/70 text-neutral-500 hover:text-neutral-300" |
||||
|
}`}
|
||||
|
> |
||||
|
Source |
||||
|
</button> |
||||
|
<button |
||||
|
onClick={() => setShowOutput(true)} |
||||
|
className={`px-1.5 py-0.5 rounded text-[9px] font-medium transition-colors ${ |
||||
|
showOutput |
||||
|
? "bg-blue-600 text-white" |
||||
|
: "bg-neutral-900/70 text-neutral-500 hover:text-neutral-300" |
||||
|
}`}
|
||||
|
> |
||||
|
Trimmed |
||||
|
</button> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Clear output button */} |
||||
|
{nodeData.outputVideo && ( |
||||
|
<button |
||||
|
onClick={() => { |
||||
|
updateNodeData(id, { outputVideo: null, status: "idle" }); |
||||
|
setShowOutput(false); |
||||
|
}} |
||||
|
className="absolute top-1 right-1 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" |
||||
|
title="Clear trimmed video" |
||||
|
> |
||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> |
||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> |
||||
|
</svg> |
||||
|
</button> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* Trim controls (only shown when we have a duration) */} |
||||
|
{hasSourceVideo && ( |
||||
|
<div className="shrink-0 flex flex-col gap-1.5 px-1"> |
||||
|
{/* Dual range slider */} |
||||
|
<div className="nodrag nowheel relative h-5 flex items-center"> |
||||
|
{/* Track background */} |
||||
|
<div className="absolute left-0 right-0 h-1.5 bg-neutral-700 rounded-full" /> |
||||
|
|
||||
|
{/* Highlighted trim range */} |
||||
|
<div |
||||
|
className="absolute h-1.5 bg-blue-500/60 rounded-full pointer-events-none" |
||||
|
style={{ left: `${startPct}%`, right: `${100 - endPct}%` }} |
||||
|
/> |
||||
|
|
||||
|
{/* Start slider */} |
||||
|
<input |
||||
|
type="range" |
||||
|
min={0} |
||||
|
max={duration > 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 */} |
||||
|
<input |
||||
|
type="range" |
||||
|
min={0} |
||||
|
max={duration > 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 */} |
||||
|
<div |
||||
|
className="absolute w-3 h-3 bg-white rounded-full border-2 border-blue-500 pointer-events-none" |
||||
|
style={{ left: `calc(${startPct}% - 6px)`, zIndex: 4 }} |
||||
|
/> |
||||
|
<div |
||||
|
className="absolute w-3 h-3 bg-white rounded-full border-2 border-blue-500 pointer-events-none" |
||||
|
style={{ left: `calc(${endPct}% - 6px)`, zIndex: 4 }} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* Time labels */} |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<div className="flex flex-col items-start"> |
||||
|
<span className="text-[10px] text-neutral-400">Start</span> |
||||
|
<span className="text-[11px] text-neutral-200 font-mono">{formatTime(startTime)}</span> |
||||
|
</div> |
||||
|
<div className="flex flex-col items-center"> |
||||
|
<span className="text-[10px] text-neutral-400">Duration</span> |
||||
|
<span className="text-[11px] text-neutral-200 font-mono">{formatTime(trimDuration)}</span> |
||||
|
</div> |
||||
|
<div className="flex flex-col items-end"> |
||||
|
<span className="text-[10px] text-neutral-400">End</span> |
||||
|
<span className="text-[11px] text-neutral-200 font-mono">{formatTime(endTime)}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Trim button */} |
||||
|
<div className="shrink-0 flex justify-end px-1"> |
||||
|
<button |
||||
|
onClick={handleTrim} |
||||
|
disabled={!canTrim || nodeData.status === "loading" || isRunning} |
||||
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-neutral-700 disabled:text-neutral-500 disabled:cursor-not-allowed rounded text-white text-xs font-medium transition-colors" |
||||
|
> |
||||
|
{nodeData.status === "loading" ? "Processing..." : "Trim"} |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
{/* Processing overlay */} |
||||
|
{nodeData.status === "loading" && ( |
||||
|
<div className="absolute inset-0 bg-neutral-900/70 rounded flex flex-col items-center justify-center gap-2"> |
||||
|
<svg className="w-6 h-6 animate-spin text-white" fill="none" viewBox="0 0 24 24"> |
||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" /> |
||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> |
||||
|
</svg> |
||||
|
<span className="text-white text-xs">Processing... {Math.round(nodeData.progress)}%</span> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Error display */} |
||||
|
{nodeData.status === "error" && nodeData.error && ( |
||||
|
<div className="shrink-0 px-2 py-1.5 bg-red-900/30 border border-red-700/50 rounded"> |
||||
|
<p className="text-[10px] text-red-400 break-words">{nodeData.error}</p> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
</BaseNode> |
||||
|
); |
||||
|
} |
||||
Loading…
Reference in new issue