From 9c7173a2f365fbf36ab4351266a9286c21880e01 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 21 Feb 2026 22:57:35 +1300 Subject: [PATCH] fix: add seek timeout and fingerprint cache to VideoStitch thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thumbnail extraction effect had three issues compounding on weak hardware: 1. The onseeked promise had no timeout — if seek hangs on systems without hardware video decoding, the entire effect blocks indefinitely. Added 10-second Promise.race timeout. 2. Created video elements were never cleaned up after use, leaving phantom decoders running. Added cleanupVideo() that nulls handlers, clears src, and calls load(). 3. The thumbnail cache was cleared wholesale on every run (thumbnailsRef.current = new Map()), making the cache check on the next line always miss. Replaced with fingerprint-based invalidation: only clips whose video data actually changed get re-extracted, reusing cached thumbnails for unchanged clips. Co-Authored-By: Claude Opus 4.6 --- src/components/nodes/VideoStitchNode.tsx | 42 +++++++++++++++++++----- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/components/nodes/VideoStitchNode.tsx b/src/components/nodes/VideoStitchNode.tsx index accee880..c39a86d0 100644 --- a/src/components/nodes/VideoStitchNode.tsx +++ b/src/components/nodes/VideoStitchNode.tsx @@ -131,44 +131,66 @@ export function VideoStitchNode({ id, data, selected }: NodeProps>(new Map()); + // Fingerprint cache: edgeId -> last-20-chars of videoData, used to detect which clips changed + const thumbnailFingerprintsRef = useRef>(new Map()); // Extract thumbnails from connected videos useEffect(() => { let cancelled = false; + const cleanupVideo = (video: HTMLVideoElement) => { + video.onloadedmetadata = null; + video.onerror = null; + video.onseeked = null; + video.src = ""; + video.load(); + }; + const extractThumbnails = async () => { - thumbnailsRef.current = new Map(); const newThumbnails = new Map(); + const newFingerprints = new Map(); for (const clip of orderedClips) { if (cancelled) return; 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)!); continue; } + const video = document.createElement("video"); try { - const video = document.createElement("video"); video.src = clip.videoData; video.crossOrigin = "anonymous"; video.muted = true; + video.preload = "metadata"; await new Promise((resolve, reject) => { video.onloadedmetadata = () => resolve(); video.onerror = () => reject(new Error("Failed to load video")); }); - if (cancelled) return; + if (cancelled) { cleanupVideo(video); return; } const seekTime = video.duration * 0.25; video.currentTime = seekTime; - await new Promise((resolve) => { - video.onseeked = () => resolve(); - }); + await Promise.race([ + new Promise((resolve) => { + video.onseeked = () => resolve(); + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Seek timeout")), 10_000) + ), + ]); - if (cancelled) return; + if (cancelled) { cleanupVideo(video); return; } const canvas = document.createElement("canvas"); const thumbWidth = 160; @@ -177,7 +199,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps