diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 302ffea0..1364f0ac 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -531,7 +531,7 @@ function App() { const bigWaveformEnabled = waveformEnabled && waveformMode === 'big-waveform'; const showThumbnails = thumbnailsEnabled && hasVideo; - const { cancelRenderThumbnails, thumbnailsSorted, setThumbnails } = useThumbnails({ filePath, zoomedDuration, zoomWindowStartTime, showThumbnails }); + const { thumbnailsSorted, setThumbnails } = useThumbnails({ filePath, zoomedDuration, zoomWindowStartTime, showThumbnails }); const shouldShowKeyframes = keyframesEnabled && hasVideo && calcShouldShowKeyframes(zoomedDuration); const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration); @@ -581,9 +581,7 @@ function App() { setHideMediaSourcePlayer(false); setExportConfirmVisible(false); setOutputPlaybackRateState(1); - - cancelRenderThumbnails(); - }, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setDuration, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, setOutputPlaybackRateState, cancelRenderThumbnails]); + }, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setDuration, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, setOutputPlaybackRateState]); const showUnsupportedFileMessage = useCallback(() => { diff --git a/src/renderer/src/ffmpeg.ts b/src/renderer/src/ffmpeg.ts index 786a9ab4..640b2658 100644 --- a/src/renderer/src/ffmpeg.ts +++ b/src/renderer/src/ffmpeg.ts @@ -506,7 +506,7 @@ export async function extractStreams({ filePath, customOutDir, streams, enableOv ]; } -async function renderThumbnail(filePath: string, timestamp: number) { +async function renderThumbnail(filePath: string, timestamp: number, signal: AbortSignal) { const args = [ '-ss', String(timestamp), '-i', filePath, @@ -517,7 +517,7 @@ async function renderThumbnail(filePath: string, timestamp: number) { '-', ]; - const { stdout } = await runFfmpeg(args); + const { stdout } = await runFfmpeg(args, { signal }); const blob = new Blob([fixRemoteBuffer(stdout)], { type: 'image/jpeg' }); return URL.createObjectURL(blob); @@ -561,24 +561,19 @@ export async function extractSubtitleTrackVtt(filePath: string, streamId: number return URL.createObjectURL(blob); } -export async function renderThumbnails({ filePath, from, duration, onThumbnail }: { - filePath: string, from: number, duration: number, onThumbnail: (a: { time: number, url: string }) => void, +export async function renderThumbnails({ filePath, from, duration, onThumbnail, signal }: { + filePath: string, + from: number, + duration: number, + onThumbnail: (a: { time: number, url: string }) => void, + signal: AbortSignal, }) { - // Time first render to determine how many to render - const startTime = Date.now() / 1000; - let url = await renderThumbnail(filePath, from); - const endTime = Date.now() / 1000; - onThumbnail({ time: from, url }); - - // Aim for max 3 sec to render all - const numThumbs = Math.floor(Math.min(Math.max(3 / (endTime - startTime), 3), 10)); - // console.log(numThumbs); - - const thumbTimes = Array.from({ length: numThumbs - 1 }).fill(undefined).map((_unused, i) => (from + ((duration * (i + 1)) / (numThumbs)))); + const numThumbs = 10; + const thumbTimes = Array.from({ length: numThumbs }).fill(undefined).map((_unused, i) => (from + ((duration * i) / numThumbs))); // console.log(thumbTimes); await pMap(thumbTimes, async (time) => { - url = await renderThumbnail(filePath, time); + const url = await renderThumbnail(filePath, time, signal); onThumbnail({ time, url }); }, { concurrency: 2 }); } diff --git a/src/renderer/src/hooks/useThumbnails.ts b/src/renderer/src/hooks/useThumbnails.ts index c7c32681..9d633a03 100644 --- a/src/renderer/src/hooks/useThumbnails.ts +++ b/src/renderer/src/hooks/useThumbnails.ts @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this +import { useEffect, useMemo, useState } from 'react'; +import { useDebounce } from 'use-debounce'; import invariant from 'tiny-invariant'; import sortBy from 'lodash/sortBy'; @@ -15,51 +15,48 @@ export default ({ filePath, zoomedDuration, zoomWindowStartTime, showThumbnails showThumbnails: boolean, }) => { const [thumbnails, setThumbnails] = useState([]); - const thumnailsRef = useRef([]); - const thumnailsRenderingPromiseRef = useRef>(); - function addThumbnail(thumbnail) { - // console.log('Rendered thumbnail', thumbnail.url); - setThumbnails((v) => [...v, thumbnail]); - } + const [debounced] = useDebounce({ zoomedDuration, filePath, zoomWindowStartTime, showThumbnails }, 300, { + equalityFn: (a, b) => JSON.stringify(a) === JSON.stringify(b), + }); - const [, cancelRenderThumbnails] = useDebounceOld(() => { - async function renderThumbnails() { - if (!showThumbnails || thumnailsRenderingPromiseRef.current) return; + useEffect(() => { + const abortController = new AbortController(); + const thumbnails2: Thumbnail[] = []; + + (async () => { + if (!isDurationValid(debounced.zoomedDuration) || !debounced.showThumbnails) return; try { - setThumbnails([]); - invariant(filePath != null); - invariant(zoomedDuration != null); - const promise = ffmpegRenderThumbnails({ filePath, from: zoomWindowStartTime, duration: zoomedDuration, onThumbnail: addThumbnail }); - thumnailsRenderingPromiseRef.current = promise; - await promise; + invariant(debounced.filePath != null); + invariant(debounced.zoomedDuration != null); + + const addThumbnail = (t: Thumbnail) => { + if (abortController.signal.aborted) return; // because the bridge is async + thumbnails2.push(t); + setThumbnails((v) => [...v, t]); + }; + + await ffmpegRenderThumbnails({ signal: abortController.signal, filePath: debounced.filePath, from: debounced.zoomWindowStartTime, duration: debounced.zoomedDuration, onThumbnail: addThumbnail }); } catch (err) { - console.error('Failed to render thumbnail', err); - } finally { - thumnailsRenderingPromiseRef.current = undefined; + if ((err as Error).name !== 'AbortError') { + console.error('Failed to render thumbnails', err); + } } - } + })(); - if (isDurationValid(zoomedDuration)) renderThumbnails(); - }, 500, [zoomedDuration, filePath, zoomWindowStartTime, showThumbnails]); - - // Cleanup removed thumbnails - useEffect(() => { - thumnailsRef.current.forEach((thumbnail) => { - if (!thumbnails.some((nextThumbnail) => nextThumbnail.url === thumbnail.url)) { - console.log('Cleanup thumbnail', thumbnail.time); - URL.revokeObjectURL(thumbnail.url); - } - }); - thumnailsRef.current = thumbnails; - }, [thumbnails]); + return () => { + abortController.abort(); + console.log('Cleanup thumbnails', thumbnails2.map((t) => t.time)); + thumbnails2.forEach((thumbnail) => URL.revokeObjectURL(thumbnail.url)); + setThumbnails([]); + }; + }, [debounced]); const thumbnailsSorted = useMemo(() => sortBy(thumbnails, (thumbnail) => thumbnail.time), [thumbnails]); return { thumbnailsSorted, setThumbnails, - cancelRenderThumbnails, }; };