@ -131,44 +131,66 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
// Ref-based cache so the effect doesn't read stale `thumbnails` state
// Ref-based cache so the effect doesn't read stale `thumbnails` state
const thumbnailsRef = useRef < Map < string , string > > ( new Map ( ) ) ;
const thumbnailsRef = useRef < Map < string , string > > ( new Map ( ) ) ;
// Fingerprint cache: edgeId -> last-20-chars of videoData, used to detect which clips changed
const thumbnailFingerprintsRef = useRef < Map < string , string > > ( new Map ( ) ) ;
// Extract thumbnails from connected videos
// Extract thumbnails from connected videos
useEffect ( ( ) = > {
useEffect ( ( ) = > {
let cancelled = false ;
let cancelled = false ;
const cleanupVideo = ( video : HTMLVideoElement ) = > {
video . onloadedmetadata = null ;
video . onerror = null ;
video . onseeked = null ;
video . src = "" ;
video . load ( ) ;
} ;
const extractThumbnails = async ( ) = > {
const extractThumbnails = async ( ) = > {
thumbnailsRef . current = new Map ( ) ;
const newThumbnails = new Map < string , string > ( ) ;
const newThumbnails = new Map < string , string > ( ) ;
const newFingerprints = new Map < string , string > ( ) ;
for ( const clip of orderedClips ) {
for ( const clip of orderedClips ) {
if ( cancelled ) return ;
if ( cancelled ) return ;
if ( ! clip . videoData ) continue ;
if ( ! clip . videoData ) continue ;
if ( thumbnailsRef . current . has ( clip . edgeId ) ) {
const fingerprint = clip . videoData . slice ( - 20 ) ;
newFingerprints . set ( clip . edgeId , fingerprint ) ;
// Reuse cached thumbnail if the video data hasn't changed
const cachedFingerprint = thumbnailFingerprintsRef . current . get ( clip . edgeId ) ;
if ( cachedFingerprint === fingerprint && thumbnailsRef . current . has ( clip . edgeId ) ) {
newThumbnails . set ( clip . edgeId , thumbnailsRef . current . get ( clip . edgeId ) ! ) ;
newThumbnails . set ( clip . edgeId , thumbnailsRef . current . get ( clip . edgeId ) ! ) ;
continue ;
continue ;
}
}
const video = document . createElement ( "video" ) ;
try {
try {
const video = document . createElement ( "video" ) ;
video . src = clip . videoData ;
video . src = clip . videoData ;
video . crossOrigin = "anonymous" ;
video . crossOrigin = "anonymous" ;
video . muted = true ;
video . muted = true ;
video . preload = "metadata" ;
await new Promise < void > ( ( resolve , reject ) = > {
await new Promise < void > ( ( resolve , reject ) = > {
video . onloadedmetadata = ( ) = > resolve ( ) ;
video . onloadedmetadata = ( ) = > resolve ( ) ;
video . onerror = ( ) = > reject ( new Error ( "Failed to load video" ) ) ;
video . onerror = ( ) = > reject ( new Error ( "Failed to load video" ) ) ;
} ) ;
} ) ;
if ( cancelled ) return ;
if ( cancelled ) { cleanupVideo ( video ) ; return ; }
const seekTime = video . duration * 0.25 ;
const seekTime = video . duration * 0.25 ;
video . currentTime = seekTime ;
video . currentTime = seekTime ;
await new Promise < void > ( ( resolve ) = > {
await Promise . race ( [
video . onseeked = ( ) = > resolve ( ) ;
new Promise < void > ( ( resolve ) = > {
} ) ;
video . onseeked = ( ) = > resolve ( ) ;
} ) ,
new Promise < void > ( ( _ , reject ) = >
setTimeout ( ( ) = > reject ( new Error ( "Seek timeout" ) ) , 10 _000 )
) ,
] ) ;
if ( cancelled ) return ;
if ( cancelled ) { cleanupVideo ( video ) ; return ; }
const canvas = document . createElement ( "canvas" ) ;
const canvas = document . createElement ( "canvas" ) ;
const thumbWidth = 160 ;
const thumbWidth = 160 ;
@ -177,7 +199,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
canvas . width = thumbWidth ;
canvas . width = thumbWidth ;
canvas . height = Math . round ( thumbWidth / aspectRatio ) ;
canvas . height = Math . round ( thumbWidth / aspectRatio ) ;
const ctx = canvas . getContext ( "2d" ) ;
const ctx = canvas . getContext ( "2d" ) ;
if ( ! ctx ) continue ;
if ( ! ctx ) { cleanupVideo ( video ) ; continue ; }
ctx . drawImage ( video , 0 , 0 , canvas . width , canvas . height ) ;
ctx . drawImage ( video , 0 , 0 , canvas . width , canvas . height ) ;
const thumbnail = canvas . toDataURL ( "image/jpeg" , 0.7 ) ;
const thumbnail = canvas . toDataURL ( "image/jpeg" , 0.7 ) ;
@ -187,10 +209,12 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
} catch ( error ) {
} catch ( error ) {
console . warn ( ` Failed to extract thumbnail for clip ${ clip . edgeId } : ` , error ) ;
console . warn ( ` Failed to extract thumbnail for clip ${ clip . edgeId } : ` , error ) ;
}
}
cleanupVideo ( video ) ;
}
}
if ( ! cancelled ) {
if ( ! cancelled ) {
thumbnailsRef . current = newThumbnails ;
thumbnailsRef . current = newThumbnails ;
thumbnailFingerprintsRef . current = newFingerprints ;
setThumbnails ( newThumbnails ) ;
setThumbnails ( newThumbnails ) ;
}
}
} ;
} ;