From 2502204d28a8f1268780385f85fd6cdc93cff958 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Thu, 20 Feb 2025 13:54:25 +0800 Subject: [PATCH] allow extracting markers as images too --- src/main/ffmpeg.ts | 27 +++++++++++++++++------ src/renderer/src/App.tsx | 25 +++++++++++---------- src/renderer/src/SegmentList.tsx | 16 +++++++++----- src/renderer/src/hooks/useFrameCapture.ts | 2 +- 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index 8d87fe5becf..1eb8808d303 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -444,7 +444,7 @@ function getCodecOpts(captureFormat: CaptureFormat) { export async function captureFrames({ from, to, videoPath, outPathTemplate, quality, filter, framePts, onProgress, captureFormat }: { from: number, - to: number, + to?: number | undefined, videoPath: string, outPathTemplate: string, quality: number, @@ -456,12 +456,20 @@ export async function captureFrames({ from, to, videoPath, outPathTemplate, qual const args = [ '-ss', String(from), '-i', videoPath, - '-t', String(Math.max(0, to - from)), + ...(to != null ? ['-t', String(Math.max(0, to - from))] : []), ...getQualityOpts({ captureFormat, quality }), - ...(filter != null ? ['-vf', filter] : []), - // https://superuser.com/questions/1336285/use-ffmpeg-for-thumbnail-selections - ...(framePts ? ['-frame_pts', '1'] : []), - '-vsync', '0', // else we get a ton of duplicates (thumbnail filter) + // only apply filter for non-markers + ...(filter != null && to != null + ? [ + '-vf', filter, + // https://superuser.com/questions/1336285/use-ffmpeg-for-thumbnail-selections + ...(framePts ? ['-frame_pts', '1'] : []), + '-vsync', '0', // else we get a ton of duplicates (thumbnail filter) + ] + : [ + '-frames:v', '1', // for markers, just capture 1 frame + ] + ), ...getCodecOpts(captureFormat), '-f', 'image2', '-y', outPathTemplate, @@ -469,9 +477,14 @@ export async function captureFrames({ from, to, videoPath, outPathTemplate, qual const process = runFfmpegProcess(args, { buffer: false }); - handleProgress(process, to - from, onProgress); + if (to != null) { + handleProgress(process, to - from, onProgress); + } await process; + + onProgress(1); + return args; } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0b6af11c08a..ea68c47b7cc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -82,13 +82,13 @@ import { askForHtml5ifySpeed } from './dialogs/html5ify'; import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, CleanupChoicesType, showOutputNotWritable } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; -import { sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, getPlaybackMode, getSegmentTags, filterNonMarkers } from './segments'; +import { sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, getPlaybackMode, getSegmentTags } from './segments'; import { generateOutSegFileNames as generateOutSegFileNamesRaw, generateMergedFileNames as generateMergedFileNamesRaw, defaultOutSegTemplate, defaultCutMergedFileTemplate } from './util/outputNameTemplate'; import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants'; import BigWaveform from './components/BigWaveform'; import isDev from './isDev'; -import { BatchFile, Chapter, CustomTagsByFile, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, goToTimecodeDirectArgsSchema, openFilesActionArgsSchema, ParamsByStreamId, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, TunerType } from './types'; +import { BatchFile, Chapter, CustomTagsByFile, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, goToTimecodeDirectArgsSchema, openFilesActionArgsSchema, ParamsByStreamId, PlaybackMode, SegmentBase, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, TunerType } from './types'; import { CaptureFormat, KeyboardAction, Html5ifyMode, WaveformMode, ApiActionRequest } from '../../../types'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; import useLoading from './hooks/useLoading'; @@ -1192,10 +1192,14 @@ function App() { }, i18n.t('Failed to capture frame')); }, [filePath, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, hideAllNotifications]); - const extractSegmentFramesAsImages = useCallback(async (segments: { start: number, end: number }[]) => { + const extractSegmentsFramesAsImages = useCallback(async (segments: SegmentBase[]) => { if (!filePath || detectedFps == null || workingRef.current) return; - const segmentsNumFrames = segments.reduce((acc, { start, end }) => acc + (getFrameCount(end - start) ?? 0), 0); - const captureFramesResponse = await askExtractFramesAsImages({ segmentsNumFrames, plural: segments.length > 1, fps: detectedFps }); + const segmentsNumFrames = segments.reduce((acc, { start, end }) => acc + (end == null ? 1 : (getFrameCount(end - start) ?? 0)), 0); + const areAllSegmentsMarkers = segments.every((seg) => seg.end == null); + const captureFramesResponse = areAllSegmentsMarkers + ? { filter: undefined, estimatedMaxNumFiles: segmentsNumFrames } + : await askExtractFramesAsImages({ segmentsNumFrames, plural: segments.length > 1, fps: detectedFps }); + if (captureFramesResponse == null) return; try { @@ -1233,13 +1237,9 @@ function App() { } }, [filePath, detectedFps, workingRef, getFrameCount, setWorking, hideAllNotifications, captureFramesRange, customOutDir, captureFormat, captureFrameQuality, captureFrameFileNameFormat, showOsNotification, outputDir]); - const extractCurrentSegmentFramesAsImages = useCallback(() => { - const { end } = currentCutSeg; - invariant(currentCutSeg != null && end != null); - extractSegmentFramesAsImages([{ ...currentCutSeg, end }]); - }, [currentCutSeg, extractSegmentFramesAsImages]); + const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentsFramesAsImages([currentCutSeg]), [currentCutSeg, extractSegmentsFramesAsImages]); - const extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentFramesAsImages(filterNonMarkers(selectedSegments)), [extractSegmentFramesAsImages, selectedSegments]); + const extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentsFramesAsImages(selectedSegments), [extractSegmentsFramesAsImages, selectedSegments]); const userChangePlaybackRate = useCallback((dir: number, rateMultiplier?: number) => { if (compatPlayerEnabled) { @@ -2532,7 +2532,8 @@ function App() { onDeselectAllSegments={deselectAllSegments} onSelectAllSegments={selectAllSegments} onInvertSelectedSegments={invertSelectedSegments} - onExtractSegmentFramesAsImages={extractSegmentFramesAsImages} + onExtractSegmentFramesAsImages={extractSegmentsFramesAsImages} + onExtractSelectedSegmentsFramesAsImages={extractSelectedSegmentsFramesAsImages} jumpSegStart={jumpSegStart} jumpSegEnd={jumpSegEnd} onSelectSegmentsByLabel={selectSegmentsByLabel} diff --git a/src/renderer/src/SegmentList.tsx b/src/renderer/src/SegmentList.tsx index e77118007d5..dc21297643f 100644 --- a/src/renderer/src/SegmentList.tsx +++ b/src/renderer/src/SegmentList.tsx @@ -17,7 +17,7 @@ import { useSegColors } from './contexts'; import { mySpring } from './animations'; import { getSegmentTags } from './segments'; import TagEditor from './components/TagEditor'; -import { ContextMenuTemplate, FormatTimecode, GetFrameCount, InverseCutSegment, SegmentTags, StateSegment } from './types'; +import { ContextMenuTemplate, FormatTimecode, GetFrameCount, InverseCutSegment, SegmentBase, SegmentTags, StateSegment } from './types'; import { UseSegments } from './hooks/useSegments'; @@ -55,6 +55,7 @@ const Segment = memo(({ addSegment, onEditSegmentTags, onExtractSegmentFramesAsImages, + onExtractSelectedSegmentsFramesAsImages, onInvertSelectedSegments, onDuplicateSegmentClick, }: { @@ -83,7 +84,8 @@ const Segment = memo(({ jumpSegEnd: (i: number) => void, addSegment: UseSegments['addSegment'], onEditSegmentTags: (i: number) => void, - onExtractSegmentFramesAsImages: (segments: Pick[]) => Promise, + onExtractSegmentFramesAsImages: (segments: Pick[]) => Promise, + onExtractSelectedSegmentsFramesAsImages: () => void, onInvertSelectedSegments: UseSegments['invertSelectedSegments'], onDuplicateSegmentClick: UseSegments['duplicateSegment'], }) => { @@ -124,6 +126,7 @@ const Segment = memo(({ { label: t('Label selected segments'), click: onLabelSelectedSegments }, { label: t('Remove selected segments'), click: onRemoveSelected }, { label: t('Edit segments by expression'), click: () => onMutateSegmentsByExpr() }, + { label: t('Extract frames from selected segments as image files'), click: onExtractSelectedSegmentsFramesAsImages }, { type: 'separator' }, @@ -134,9 +137,9 @@ const Segment = memo(({ { type: 'separator' }, { label: t('Segment tags'), click: () => onEditSegmentTags(index) }, - ...(seg.end != null ? [{ label: t('Extract frames as image files'), click: () => onExtractSegmentFramesAsImages([seg as Pick]) }] : []), + { label: t('Extract frames as image files'), click: () => onExtractSegmentFramesAsImages([seg]) }, ]; - }, [invertCutSegments, t, addSegment, onLabelSelectedSegments, onRemoveSelected, seg, updateSegOrder, index, jumpSegStart, jumpSegEnd, onLabelPress, onRemovePress, onDuplicateSegmentClick, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectAllMarkers, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onInvertSelectedSegments, onMutateSegmentsByExpr, onReorderPress, onEditSegmentTags, onExtractSegmentFramesAsImages]); + }, [invertCutSegments, t, addSegment, onLabelSelectedSegments, onRemoveSelected, onExtractSelectedSegmentsFramesAsImages, updateSegOrder, index, jumpSegStart, jumpSegEnd, onLabelPress, onRemovePress, onDuplicateSegmentClick, seg, onSelectSingleSegment, onSelectAllSegments, onDeselectAllSegments, onSelectAllMarkers, onSelectSegmentsByLabel, onSelectSegmentsByExpr, onInvertSelectedSegments, onMutateSegmentsByExpr, onReorderPress, onEditSegmentTags, onExtractSegmentFramesAsImages]); useContextMenu(ref, contextMenuTemplate); @@ -257,6 +260,7 @@ function SegmentList({ onMutateSegmentsByExpr, onSelectAllMarkers, onExtractSegmentFramesAsImages, + onExtractSelectedSegmentsFramesAsImages, onLabelSelectedSegments, onInvertSelectedSegments, onDuplicateSegmentClick, @@ -296,7 +300,8 @@ function SegmentList({ onSelectSegmentsByExpr: UseSegments['selectSegmentsByExpr'], onSelectAllMarkers: UseSegments['selectAllMarkers'], onMutateSegmentsByExpr: UseSegments['mutateSegmentsByExpr'], - onExtractSegmentFramesAsImages: (segments: Pick[]) => Promise, + onExtractSegmentFramesAsImages: (segments: Pick[]) => Promise, + onExtractSelectedSegmentsFramesAsImages: () => void, onLabelSelectedSegments: UseSegments['labelSelectedSegments'], onInvertSelectedSegments: UseSegments['invertSelectedSegments'], onDuplicateSegmentClick: UseSegments['duplicateSegment'], @@ -514,6 +519,7 @@ function SegmentList({ onSelectSegmentsByExpr={onSelectSegmentsByExpr} onMutateSegmentsByExpr={onMutateSegmentsByExpr} onExtractSegmentFramesAsImages={onExtractSegmentFramesAsImages} + onExtractSelectedSegmentsFramesAsImages={onExtractSelectedSegmentsFramesAsImages} onLabelSelectedSegments={onLabelSelectedSegments} onSelectAllMarkers={onSelectAllMarkers} onInvertSelectedSegments={onInvertSelectedSegments} diff --git a/src/renderer/src/hooks/useFrameCapture.ts b/src/renderer/src/hooks/useFrameCapture.ts index 7bcb6401553..7077ecff25f 100644 --- a/src/renderer/src/hooks/useFrameCapture.ts +++ b/src/renderer/src/hooks/useFrameCapture.ts @@ -35,7 +35,7 @@ export default ({ appendFfmpegCommandLog, formatTimecode, treatOutputFileModifie filePath: string, fps: number, fromTime: number, - toTime: number, + toTime: number | undefined, estimatedMaxNumFiles: number, captureFormat: CaptureFormat, quality: number,