From fb2dd3d14485876c674b304c2bb1179832302f11 Mon Sep 17 00:00:00 2001 From: Bhavya Gupta Date: Thu, 18 Jun 2026 11:24:27 +0530 Subject: [PATCH] fix: prevent memory leak and CPU overhead in thumbnail strip generator (#1500) --- src/components/ThumbnailStrip.tsx | 482 ++++-------------------------- src/components/VideoEditor.tsx | 24 +- src/hooks/useThumbnailStrip.ts | 168 +++++++++++ 3 files changed, 238 insertions(+), 436 deletions(-) create mode 100644 src/hooks/useThumbnailStrip.ts diff --git a/src/components/ThumbnailStrip.tsx b/src/components/ThumbnailStrip.tsx index f5094722..bd6145d1 100644 --- a/src/components/ThumbnailStrip.tsx +++ b/src/components/ThumbnailStrip.tsx @@ -1,446 +1,78 @@ "use client"; -import Image from "next/image"; -import { useEffect, useRef, useState, useCallback } from "react"; +import { useThumbnailStrip } from "@/hooks/useThumbnailStrip"; -interface Thumbnail { - time: number; - dataUrl: string; -} - -interface ThumbnailStripProps { - videoSrc: string | null; - duration: number; +interface Props { + file: File | null; currentTime: number; - trimStart?: number; - trimEnd?: number; - onSeek: (time: number) => void; - intervalSeconds?: number; + duration: number; + onSeek: (t: number) => void; } export default function ThumbnailStrip({ - videoSrc, - duration, + file, currentTime, - trimStart = 0, - trimEnd, + duration, onSeek, - intervalSeconds = 5, -}: ThumbnailStripProps) { - const [thumbnails, setThumbnails] = useState([]); - const [isGenerating, setIsGenerating] = useState(false); - const [progress, setProgress] = useState(0); - const [hoveredIndex, setHoveredIndex] = useState(null); - const stripRef = useRef(null); - const offscreenVideoRef = useRef(null); - const lastRunIdRef = useRef(0); - const objectUrlsRef = useRef([]); - - const effectiveTrimEnd = trimEnd ?? duration; - - const revokeAllObjectUrls = useCallback(() => { - objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); - objectUrlsRef.current = []; - }, []); - - const cancelThumbnailRun = useCallback(() => { - lastRunIdRef.current += 1; - }, []); - - const generateThumbnails = useCallback(async () => { - if (!videoSrc || duration <= 0) return; - - const runId = ++lastRunIdRef.current; - setIsGenerating(true); - revokeAllObjectUrls(); - setThumbnails([]); - setProgress(0); - - const video = document.createElement("video"); - offscreenVideoRef.current = video; - - try { - video.src = videoSrc; - video.crossOrigin = "anonymous"; - video.muted = true; - video.preload = "auto"; - - await new Promise((resolve, reject) => { - video.onloadedmetadata = () => resolve(); - video.onerror = () => reject(new Error("Video load failed")); - video.load(); - }); - - if (lastRunIdRef.current !== runId) return; - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const thumbW = 160; - const thumbH = 90; - canvas.width = thumbW; - canvas.height = thumbH; +}: Props) { + const { thumbnails, isGenerating } = useThumbnailStrip(file); - const times: number[] = []; - for (let t = 0; t <= duration; t += intervalSeconds) { - times.push(Math.min(t, duration - 0.1)); - } - if ((times[times.length - 1] ?? 0) < duration - 0.5) { - times.push(duration - 0.1); - } + if (!file) return null; - const captured: Thumbnail[] = []; - - for (let i = 0; i < times.length; i++) { - if (lastRunIdRef.current !== runId) break; - - const time = times[i] ?? 0; - await new Promise((resolve) => { - const onSeeked = async () => { - video.removeEventListener("seeked", onSeeked); - - if (lastRunIdRef.current !== runId) { - resolve(); - return; - } - - ctx.drawImage(video, 0, 0, thumbW, thumbH); - - try { - const blob = await new Promise((blobResolve) => { - canvas.toBlob((b) => blobResolve(b), "image/jpeg", 0.7); - }); - if (blob && lastRunIdRef.current === runId) { - const url = URL.createObjectURL(blob); - objectUrlsRef.current.push(url); - captured.push({ time, dataUrl: url }); - - if (i === times.length - 1 || captured.length % 5 === 0) { - setThumbnails([...captured]); - } - } - } catch (err) { - console.error("Failed to generate thumbnail blob", err); - } - - setProgress(Math.round(((i + 1) / times.length) * 100)); - resolve(); - }; - video.addEventListener("seeked", onSeeked); - video.currentTime = time; - }); - } - - if (lastRunIdRef.current === runId) { - setIsGenerating(false); - } - } finally { - video.src = ""; - if (offscreenVideoRef.current === video) { - offscreenVideoRef.current = null; - } - } - }, [videoSrc, duration, intervalSeconds, revokeAllObjectUrls]); - - useEffect(() => { - if (videoSrc && duration > 0) { - generateThumbnails(); - } - return () => { - cancelThumbnailRun(); - revokeAllObjectUrls(); - }; - }, [cancelThumbnailRun, generateThumbnails, revokeAllObjectUrls, videoSrc, duration]); - - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, "0")}`; + const handleClick = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = (e.clientX - rect.left) / rect.width; + onSeek(Math.max(0, Math.min(ratio * duration, duration))); }; - const activeIndex = thumbnails.findIndex( - (t, i) => - currentTime >= t.time && - (i === thumbnails.length - 1 || currentTime < (thumbnails[i + 1]?.time ?? Infinity)) - ); - - if (!videoSrc) return null; - return ( -
-
- - - - - - - Frames - - {isGenerating && ( - - +
+ {/* Skeleton placeholders while generating */} + {isGenerating && + Array.from({ length: 10 }).map((_, i) => ( +
- {progress}% - - )} - {!isGenerating && thumbnails.length > 0 && ( - - {thumbnails.length} frames · every {intervalSeconds}s - - )} -
- -
- {thumbnails.length === 0 && isGenerating && ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- ))} -
- )} - - {thumbnails.length > 0 && ( -
- {thumbnails.map((thumb, i) => { - const isActive = i === activeIndex; - const inTrimRange = - thumb.time >= trimStart && thumb.time <= effectiveTrimEnd; - const isHovered = hoveredIndex === i; - - return ( - - ); - })} + ))} + + {/* Actual thumbnails */} + {!isGenerating && + thumbnails.map((url, i) => ( + {`Frame + ))} + + {/* Playhead indicator */} + {duration > 0 && ( +
+
)}
- + {isGenerating && ( +

+ Generating thumbnails… +

+ )}
); -} +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index a12c1f41..15d5097c 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -1,5 +1,6 @@ "use client"; - +import ThumbnailStrip from "./ThumbnailStrip"; +import { Strip } from "lucide-react"; import { useState, useRef, useEffect, useMemo } from "react"; import { useVideoEditor } from "@/hooks/useVideoEditor"; import { TextOverlay } from "@/lib/types"; @@ -420,16 +421,17 @@ return () => {
)} - {file && ( -
- + {file && ( +
+ + +
+)}
([]); + const [isGenerating, setIsGenerating] = useState(false); + + // Each run gets a unique ID — if a new run starts or component + // unmounts, the stale run checks this ref and bails out early, + // preventing CPU work and blob URL leaks after cancellation. + const runIdRef = useRef(0); + + // Track all blob URLs created by the current run so we can + // revoke them on cleanup even if the run was cancelled mid-way. + const blobUrlsRef = useRef([]); + + useEffect(() => { + if (!file) { + setThumbnails([]); + return; + } + + // Increment run ID — any in-flight run will see a mismatch and stop. + const runId = ++runIdRef.current; + + // Revoke any blob URLs from the previous run before starting. + blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u)); + blobUrlsRef.current = []; + + setThumbnails([]); + setIsGenerating(true); + + // One shared offscreen canvas — created once per run, destroyed on cleanup. + const canvas = document.createElement("canvas"); + canvas.width = THUMBNAIL_WIDTH; + canvas.height = THUMBNAIL_HEIGHT; + const ctx = canvas.getContext("2d"); + + // One shared video element — created once per run, destroyed on cleanup. + const video = document.createElement("video"); + video.muted = true; + video.preload = "metadata"; + video.playsInline = true; + + const objectUrl = URL.createObjectURL(file); + video.src = objectUrl; + + let cancelled = false; + + const cleanup = () => { + cancelled = true; + + // Stop seeking and free the media pipeline. + video.pause(); + video.removeAttribute("src"); + video.load(); + + // Release the object URL for the source file. + URL.revokeObjectURL(objectUrl); + + // Revoke any blob URLs we emitted — prevents memory leak + // when the run is cancelled mid-way. + blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u)); + blobUrlsRef.current = []; + }; + + const generateFrames = async (duration: number) => { + if (cancelled || runIdRef.current !== runId) return; + + const results: string[] = []; + + for (let i = 0; i < THUMBNAIL_COUNT; i++) { + // Guard: bail if a new file was selected or component unmounted. + if (cancelled || runIdRef.current !== runId) { + // Revoke any blobs we already generated in this partial run. + results.forEach((u) => URL.revokeObjectURL(u)); + return; + } + + const seekTime = (i / (THUMBNAIL_COUNT - 1)) * duration; + + await new Promise((resolve) => { + const onSeeked = () => { + video.removeEventListener("seeked", onSeeked); + + // Double-check cancellation inside the async callback. + if (cancelled || runIdRef.current !== runId) { + resolve(); + return; + } + + try { + if (ctx) { + ctx.drawImage(video, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + } + canvas.toBlob( + (blob) => { + if (!blob || cancelled || runIdRef.current !== runId) { + resolve(); + return; + } + const url = URL.createObjectURL(blob); + // Track this URL so we can revoke it on cleanup. + blobUrlsRef.current.push(url); + results.push(url); + // Stream thumbnails in as they are ready. + setThumbnails([...results]); + resolve(); + }, + "image/jpeg", + 0.7, + ); + } catch { + resolve(); + } + }; + + video.addEventListener("seeked", onSeeked); + video.currentTime = seekTime; + }); + } + + if (!cancelled && runIdRef.current === runId) { + setIsGenerating(false); + } + }; + + video.addEventListener( + "loadedmetadata", + () => { + if (cancelled || runIdRef.current !== runId) return; + const duration = isFinite(video.duration) ? video.duration : 0; + if (duration <= 0) { + setIsGenerating(false); + return; + } + generateFrames(duration).catch(() => { + if (!cancelled) setIsGenerating(false); + }); + }, + { once: true }, + ); + + video.addEventListener( + "error", + () => { + if (cancelled || runIdRef.current !== runId) return; + setIsGenerating(false); + }, + { once: true }, + ); + + // Return cleanup — runs when file changes or component unmounts. + return cleanup; + }, [file]); + + return { thumbnails, isGenerating }; +} \ No newline at end of file