Browse Source

fix: add seek timeout and fingerprint cache to VideoStitch thumbnails

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
9c7173a2f3
  1. 42
      src/components/nodes/VideoStitchNode.tsx

42
src/components/nodes/VideoStitchNode.tsx

@ -131,44 +131,66 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
// Ref-based cache so the effect doesn't read stale `thumbnails` state
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
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<string, string>();
const newFingerprints = new Map<string, string>();
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<void>((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<void>((resolve) => {
video.onseeked = () => resolve();
});
await Promise.race([
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 thumbWidth = 160;
@ -177,7 +199,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
canvas.width = thumbWidth;
canvas.height = Math.round(thumbWidth / aspectRatio);
const ctx = canvas.getContext("2d");
if (!ctx) continue;
if (!ctx) { cleanupVideo(video); continue; }
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const thumbnail = canvas.toDataURL("image/jpeg", 0.7);
@ -187,10 +209,12 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
} catch (error) {
console.warn(`Failed to extract thumbnail for clip ${clip.edgeId}:`, error);
}
cleanupVideo(video);
}
if (!cancelled) {
thumbnailsRef.current = newThumbnails;
thumbnailFingerprintsRef.current = newFingerprints;
setThumbnails(newThumbnails);
}
};

Loading…
Cancel
Save