diff --git a/public/configStore.js b/public/configStore.js index e28ca7e3..358a2d91 100644 --- a/public/configStore.js +++ b/public/configStore.js @@ -113,7 +113,7 @@ const defaults = { storeProjectInWorkingDir: true, enableOverwriteOutput: true, mouseWheelZoomModifierKey: 'ctrl', - captureFrameMethod: 'ffmpeg', + captureFrameMethod: 'videotag', // we don't default to ffmpeg because ffmpeg might choose a frame slightly off captureFrameQuality: 0.95, }; diff --git a/src/App.jsx b/src/App.jsx index 6df6bbc1..eb735411 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -48,7 +48,7 @@ import OutputFormatSelect from './components/OutputFormatSelect'; import { loadMifiLink, runStartupCheck } from './mifi'; import { controlsBackground } from './colors'; -import { captureFrameFromTag, captureFramesFfmpeg } from './capture-frame'; +import { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } from './capture-frame'; import { getStreamFps, isCuttingStart, isCuttingEnd, readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails, @@ -69,7 +69,7 @@ import { } from './util'; import { formatDuration } from './util/duration'; import { adjustRate } from './util/rate-calculator'; -import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog, askForShiftSegments, selectSegmentsByLabelDialog, confirmExtractFramesAsImages, showRefuseToOverwrite, showParametersDialog, openDirToast, openCutFinishedToast } from './dialogs'; +import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog, askForShiftSegments, selectSegmentsByLabelDialog, askExtractFramesAsImages, showRefuseToOverwrite, showParametersDialog, openDirToast, openCutFinishedToast } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; import { createSegment, getCleanCutSegments, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap, combineOverlappingSegments as combineOverlappingSegments2, isDurationValid } from './segments'; @@ -1409,8 +1409,9 @@ const App = memo(() => { try { const currentTime = getCurrentTime(); const video = videoRef.current; - const outPath = (usingPreviewFile || captureFrameMethod === 'ffmpeg') - ? await captureFramesFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, numFrames: 1, quality: captureFrameQuality }) + const useFffmpeg = usingPreviewFile || captureFrameMethod === 'ffmpeg'; + const outPath = useFffmpeg + ? await captureFrameFromFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, quality: captureFrameQuality }) : await captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps, quality: captureFrameQuality }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: `${i18n.t('Screenshot captured to:')} ${outPath}` }); @@ -1421,22 +1422,25 @@ const App = memo(() => { }, [filePath, getCurrentTime, usingPreviewFile, captureFrameMethod, customOutDir, captureFormat, enableTransferTimestamps, captureFrameQuality, hideAllNotifications]); const extractSegmentFramesAsImages = useCallback(async (index) => { - if (!filePath) return; + if (!filePath || detectedFps == null || workingRef.current) return; const { start, end } = apparentCutSegments[index]; - const numFrames = getFrameCount(end - start); - if (numFrames < 1) return; - if (!(await confirmExtractFramesAsImages({ numFrames }))) return; + const segmentNumFrames = getFrameCount(end - start); + const captureFramesResponse = await askExtractFramesAsImages({ segmentNumFrames, fps: detectedFps }); + if (captureFramesResponse == null) return; try { setWorking(i18n.t('Extracting frames')); - const outPath = await captureFramesFfmpeg({ customOutDir, filePath, fromTime: start, captureFormat, enableTransferTimestamps, numFrames, quality: captureFrameQuality }); + + setCutProgress(0); + const outPath = await captureFramesRange({ customOutDir, filePath, fromTime: start, toTime: end, captureFormat, quality: captureFrameQuality, filter: captureFramesResponse.filter, onProgress: setCutProgress }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: i18n.t('Frames extracted to: {{path}}', { path: outputDir }) }); } catch (err) { handleError(err); } finally { setWorking(); + setCutProgress(); } - }, [apparentCutSegments, captureFormat, captureFrameQuality, customOutDir, enableTransferTimestamps, filePath, getFrameCount, hideAllNotifications, outputDir, setWorking]); + }, [apparentCutSegments, captureFormat, captureFrameQuality, customOutDir, detectedFps, filePath, getFrameCount, hideAllNotifications, outputDir, setWorking]); const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentFramesAsImages(currentSegIndexSafe), [currentSegIndexSafe, extractSegmentFramesAsImages]); @@ -1782,20 +1786,20 @@ const App = memo(() => { const detectBlackScenes = useCallback(async () => { const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.blackdetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' }); if (filterOptions == null) return; - await detectSegments({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async () => blackDetect({ filePath, duration, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); - }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, duration, filePath]); + await detectSegments({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async () => blackDetect({ filePath, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); + }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath]); const detectSilentScenes = useCallback(async () => { const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.silencedetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' }); if (filterOptions == null) return; - await detectSegments({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async () => silenceDetect({ filePath, duration, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); - }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, duration, filePath]); + await detectSegments({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async () => silenceDetect({ filePath, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); + }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath]); const detectSceneChanges = useCallback(async () => { const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.sceneChange() }); if (filterOptions == null) return; - await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async () => ffmpegDetectSceneChanges({ filePath, duration, minChange: filterOptions.minChange, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); - }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, duration, filePath]); + await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async () => ffmpegDetectSceneChanges({ filePath, minChange: filterOptions.minChange, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); + }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath]); const createSegmentsFromKeyframes = useCallback(async () => { const keyframes = (await readFrames({ filePath, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end, streamIndex: mainVideoStream?.index })).filter((frame) => frame.keyframe); @@ -1911,7 +1915,7 @@ const App = memo(() => { if (!filePath) return; try { const currentTime = getCurrentTime(); - const path = await captureFramesFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, numFrames: 1, quality: captureFrameQuality }); + const path = await captureFrameFromFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, enableTransferTimestamps, quality: captureFrameQuality }); if (!(await addFileAsCoverArt(path))) return; if (!hideAllNotifications) toast.fire({ text: i18n.t('Current frame has been set as cover art') }); } catch (err) { diff --git a/src/SegmentList.jsx b/src/SegmentList.jsx index 2b8c7a27..2fb97f10 100644 --- a/src/SegmentList.jsx +++ b/src/SegmentList.jsx @@ -59,7 +59,7 @@ const Segment = memo(({ seg, index, currentSegIndex, formatTimecode, getFrameCou { type: 'separator' }, { label: t('Segment tags'), click: () => onViewSegmentTags(index) }, - { label: t('Extract all frames as images'), click: () => onExtractSegmentFramesAsImages(index) }, + { label: t('Extract frames as image files'), click: () => onExtractSegmentFramesAsImages(index) }, ]; }, [invertCutSegments, t, jumpSegStart, jumpSegEnd, addSegment, onLabelPress, onRemovePress, onReorderPress, onRemoveSelected, onLabelSelectedSegments, updateOrder, onSelectSingleSegment, seg, onSelectAllSegments, onDeselectAllSegments, onSelectSegmentsByLabel, onViewSegmentTags, index, onExtractSegmentFramesAsImages]); diff --git a/src/capture-frame.js b/src/capture-frame.js index 191beed8..4708ba48 100644 --- a/src/capture-frame.js +++ b/src/capture-frame.js @@ -3,7 +3,7 @@ import dataUriToBuffer from 'data-uri-to-buffer'; import { getSuffixedOutPath, transferTimestamps } from './util'; import { formatDuration } from './util/duration'; -import { captureFrames as ffmpegCaptureFrames } from './ffmpeg'; +import { captureFrame as ffmpegCaptureFrame, captureFrames as ffmpegCaptureFrames } from './ffmpeg'; const fs = window.require('fs-extra'); const mime = window.require('mime-types'); @@ -20,19 +20,25 @@ function getFrameFromVideo(video, format, quality) { return dataUriToBuffer(dataUri); } -export async function captureFramesFfmpeg({ customOutDir, filePath, fromTime, captureFormat, enableTransferTimestamps, numFrames, quality }) { +export async function captureFramesRange({ customOutDir, filePath, fromTime, toTime, captureFormat, quality, filter, onProgress }) { const time = formatDuration({ seconds: fromTime, fileNameFriendly: true }); - let nameSuffix; - if (numFrames > 1) { - const numDigits = Math.floor(Math.log10(numFrames)) + 1; - nameSuffix = `${time}-%0${numDigits}d.${captureFormat}`; - } else { - nameSuffix = `${time}.${captureFormat}`; - } + const numDigits = 5; + const getSuffix = (numPart) => `${time}-${numPart}.${captureFormat}`; + const nameTemplateSuffix = getSuffix(`%0${numDigits}d`); + const nameSuffix = getSuffix(`${'1'.padStart(numDigits, '0')}`); // mimic ffmpeg + const outPathTemplate = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: nameTemplateSuffix }); + const firstFileOutPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix }); + await ffmpegCaptureFrames({ from: fromTime, to: toTime, videoPath: filePath, outPathTemplate, quality, filter, onProgress }); + return firstFileOutPath; +} + +export async function captureFrameFromFfmpeg({ customOutDir, filePath, fromTime, captureFormat, enableTransferTimestamps, quality }) { + const time = formatDuration({ seconds: fromTime, fileNameFriendly: true }); + const nameSuffix = `${time}.${captureFormat}`; const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix }); - await ffmpegCaptureFrames({ timestamp: fromTime, videoPath: filePath, outPath, numFrames, quality }); + await ffmpegCaptureFrame({ timestamp: fromTime, videoPath: filePath, outPath, quality }); - if (enableTransferTimestamps && numFrames === 1) await transferTimestamps(filePath, outPath, fromTime); + if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, fromTime); return outPath; } diff --git a/src/components/KeyboardShortcuts.jsx b/src/components/KeyboardShortcuts.jsx index 8858827e..ebb3a090 100644 --- a/src/components/KeyboardShortcuts.jsx +++ b/src/components/KeyboardShortcuts.jsx @@ -375,7 +375,7 @@ const KeyboardShortcuts = memo(({ category: outputCategory, }, extractCurrentSegmentFramesAsImages: { - name: t('Extract all frames in segment as images'), + name: t('Extract frames from segment as image files'), category: outputCategory, }, cleanupFilesDialog: { diff --git a/src/dialogs.jsx b/src/dialogs.jsx index 52720f64..1c61aa38 100644 --- a/src/dialogs.jsx +++ b/src/dialogs.jsx @@ -355,15 +355,6 @@ export async function confirmExtractAllStreamsDialog() { return !!value; } -export async function confirmExtractFramesAsImages({ numFrames }) { - const { value } = await Swal.fire({ - text: i18n.t('Please confirm that you want to extract all {{numFrames}} frames as separate images', { numFrames }), - showCancelButton: true, - confirmButtonText: i18n.t('Extract all frames'), - }); - return !!value; -} - const CleanupChoices = ({ cleanupChoicesInitial, onChange: onChangeProp }) => { const [choices, setChoices] = useState(cleanupChoicesInitial); @@ -472,6 +463,107 @@ export async function showParametersDialog({ title, description, parameters: par return Object.fromEntries(Object.entries(parameters).map(([key, parameter]) => [key, parameter.value])); } +export async function askExtractFramesAsImages({ segmentNumFrames, fps }) { + const { value: captureChoice } = await Swal.fire({ + text: i18n.t('Extract frames of the selected segment as images?'), + icon: 'question', + input: 'radio', + inputValue: 'thumbnailFilter', + showCancelButton: true, + customClass: { input: 'swal2-losslesscut-radio' }, + inputOptions: { + thumbnailFilter: i18n.t('Capture the best image every nth second'), + selectNthSec: i18n.t('Capture exactly one image every nth second'), + selectNthFrame: i18n.t('Capture exactly one image every nth frame'), + selectScene: i18n.t('Capture frames that differ the most from the previous frame'), + everyFrame: i18n.t('Capture every single frame as an image'), + }, + }); + + if (!captureChoice) return undefined; + + let filter; + let estimatedMaxNumFiles = segmentNumFrames; + + if (captureChoice === 'thumbnailFilter') { + const { value } = await Swal.fire({ + text: i18n.t('Capture the best image every nth second'), + icon: 'question', + input: 'text', + inputLabel: i18n.t('Enter a decimal number of seconds'), + inputValue: 5, + showCancelButton: true, + }); + if (value == null) return undefined; + const intervalFrames = Math.round(parseFloat(value) * fps); + if (Number.isNaN(intervalFrames) || intervalFrames < 1 || intervalFrames > 1000) return undefined; // a too large value uses a lot of memory + + filter = `thumbnail=${intervalFrames}`; + estimatedMaxNumFiles = Math.round(segmentNumFrames / intervalFrames); + } + + if (captureChoice === 'selectNthSec' || captureChoice === 'selectNthFrame') { + let nthFrame; + if (captureChoice === 'selectNthFrame') { + const { value } = await Swal.fire({ + text: i18n.t('Capture exactly one image every nth frame'), + icon: 'question', + input: 'number', + inputLabel: i18n.t('Enter an integer number of frames'), + inputValue: 30, + showCancelButton: true, + }); + if (value == null) return undefined; + const intervalFrames = parseInt(value, 10); + if (Number.isNaN(intervalFrames) || intervalFrames < 1) return undefined; + nthFrame = intervalFrames; + } else { + const { value } = await Swal.fire({ + text: i18n.t('Capture exactly one image every nth second'), + icon: 'question', + input: 'text', + inputLabel: i18n.t('Enter a decimal number of seconds'), + inputValue: 5, + showCancelButton: true, + }); + if (value == null) return undefined; + const intervalFrames = Math.round(parseFloat(value) * fps); + if (Number.isNaN(intervalFrames) || intervalFrames < 1) return undefined; + nthFrame = intervalFrames; + } + + filter = `select=not(mod(n\\,${nthFrame}))`; + estimatedMaxNumFiles = Math.round(segmentNumFrames / nthFrame); + } + if (captureChoice === 'selectScene') { + const { value } = await Swal.fire({ + text: i18n.t('Capture frames that differ the most from the previous frame'), + icon: 'question', + input: 'text', + inputLabel: i18n.t('Enter a decimal number between 0 and 1 (sane values are 0.3 - 0.5)'), + inputValue: '0.4', + showCancelButton: true, + }); + if (value == null) return undefined; + const minSceneChange = parseFloat(value); + if (Number.isNaN(minSceneChange) || minSceneChange <= 0 || minSceneChange >= 1) return undefined; + + filter = `select=gt(scene\\,${minSceneChange})`; + // we don't know estimatedMaxNumFiles here + } + + if (estimatedMaxNumFiles > 1000) { + const { isConfirmed } = await Swal.fire({ + icon: 'warning', + text: i18n.t('Note that depending on input parameters, up to {{estimatedMaxNumFiles}} files may be produced!', { estimatedMaxNumFiles }), + showCancelButton: true, + confirmButtonText: i18n.t('Confirm'), + }); + if (!isConfirmed) return undefined; + } + + return { filter }; +} export async function createFixedDurationSegments(fileDuration) { const segmentDuration = await askForSegmentDuration(fileDuration); diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 7df67d48..35343507 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -57,14 +57,13 @@ export async function runFfprobe(args, { timeout = isDev ? 10000 : 30000 } = {}) } } -export function runFfmpeg(args) { +export function runFfmpeg(args, execaOptions, { logCli = true } = {}) { const ffmpegPath = getFfmpegPath(); - console.log(getFfCommandLine('ffmpeg', args)); - return execa(ffmpegPath, args); + if (logCli) console.log(getFfCommandLine('ffmpeg', args)); + return execa(ffmpegPath, args, execaOptions); } - -export function handleProgress(process, cutDuration, onProgress, customMatcher = () => {}) { +export function handleProgress(process, durationIn, onProgress, customMatcher = () => {}) { if (!onProgress) return; onProgress(0); @@ -85,7 +84,11 @@ export function handleProgress(process, cutDuration, onProgress, customMatcher = // console.log(str); const progressTime = Math.max(0, moment.duration(str).asSeconds()); // console.log(progressTime); - const progress = cutDuration ? Math.min(progressTime / cutDuration, 1) : 0; // sometimes progressTime will be greater than cutDuration + + if (durationIn == null) return; + const duration = Math.max(0, durationIn); + if (duration === 0) return; + const progress = duration ? Math.min(progressTime / duration, 1) : 0; // sometimes progressTime will be greater than cutDuration onProgress(progress); } catch (err) { console.log('Failed to parse ffmpeg progress line', err); @@ -482,8 +485,7 @@ async function renderThumbnail(filePath, timestamp) { '-', ]; - const ffmpegPath = await getFfmpegPath(); - const { stdout } = await execa(ffmpegPath, args, { encoding: null }); + const { stdout } = await runFfmpeg(args, { encoding: null }); const blob = new Blob([stdout], { type: 'image/jpeg' }); return URL.createObjectURL(blob); @@ -498,8 +500,7 @@ export async function extractSubtitleTrack(filePath, streamId) { '-', ]; - const ffmpegPath = await getFfmpegPath(); - const { stdout } = await execa(ffmpegPath, args, { encoding: null }); + const { stdout } = await runFfmpeg(args, { encoding: null }); const blob = new Blob([stdout], { type: 'text/vtt' }); return URL.createObjectURL(blob); @@ -557,9 +558,8 @@ export async function renderWaveformPng({ filePath, aroundTime, window, color }) let ps1; let ps2; try { - const ffmpegPath = getFfmpegPath(); - ps1 = execa(ffmpegPath, args1, { encoding: null, buffer: false }); - ps2 = execa(ffmpegPath, args2, { encoding: null }); + ps1 = runFfmpeg(args1, { encoding: null, buffer: false }); + ps2 = runFfmpeg(args2, { encoding: null }); ps1.stdout.pipe(ps2.stdin); const timer = setTimeout(() => { @@ -614,18 +614,18 @@ export function mapTimesToSegments(times) { } // https://stackoverflow.com/questions/35675529/using-ffmpeg-how-to-do-a-scene-change-detection-with-timecode -export async function detectSceneChanges({ filePath, duration, minChange, onProgress, from, to }) { +export async function detectSceneChanges({ filePath, minChange, onProgress, from, to }) { const args = [ '-hide_banner', ...getInputSeekArgs({ filePath, from, to }), '-filter_complex', `select='gt(scene,${minChange})',metadata=print:file=-`, '-f', 'null', '-', ]; - const process = execa(getFfmpegPath(), args, { encoding: null, buffer: false }); + const process = runFfmpeg(args, { encoding: null, buffer: false }); const times = [0]; - handleProgress(process, duration, onProgress); + handleProgress(process, to - from, onProgress); const rl = readline.createInterface({ input: process.stdout }); rl.on('line', (line) => { const match = line.match(/^frame:\d+\s+pts:\d+\s+pts_time:([\d.]+)/); @@ -643,14 +643,14 @@ export async function detectSceneChanges({ filePath, duration, minChange, onProg } -export async function detectIntervals({ filePath, duration, customArgs, onProgress, from, to, matchLineTokens }) { +export async function detectIntervals({ filePath, customArgs, onProgress, from, to, matchLineTokens }) { const args = [ '-hide_banner', ...getInputSeekArgs({ filePath, from, to }), ...customArgs, '-f', 'null', '-', ]; - const process = execa(getFfmpegPath(), args, { encoding: null, buffer: false }); + const process = runFfmpeg(args, { encoding: null, buffer: false }); const segments = []; @@ -661,7 +661,7 @@ export async function detectIntervals({ filePath, duration, customArgs, onProgre if (start == null || end == null || Number.isNaN(start) || Number.isNaN(end)) return; segments.push({ start, end }); } - handleProgress(process, duration, onProgress, customMatcher); + handleProgress(process, to - from, onProgress, customMatcher); await process; return adjustSegmentsWithOffset({ segments, from }); @@ -669,7 +669,7 @@ export async function detectIntervals({ filePath, duration, customArgs, onProgre const mapFilterOptions = (options) => Object.entries(options).map(([key, value]) => `${key}=${value}`).join(':'); -export async function blackDetect({ filePath, duration, filterOptions, onProgress, from, to }) { +export async function blackDetect({ filePath, filterOptions, onProgress, from, to }) { function matchLineTokens(line) { const match = line.match(/^[blackdetect\s*@\s*0x[0-9a-f]+] black_start:([\d\\.]+) black_end:([\d\\.]+) black_duration:[\d\\.]+/); if (!match) return {}; @@ -679,10 +679,10 @@ export async function blackDetect({ filePath, duration, filterOptions, onProgres }; } const customArgs = ['-vf', `blackdetect=${mapFilterOptions(filterOptions)}`, '-an']; - return detectIntervals({ filePath, duration, onProgress, from, to, matchLineTokens, customArgs }); + return detectIntervals({ filePath, onProgress, from, to, matchLineTokens, customArgs }); } -export async function silenceDetect({ filePath, duration, filterOptions, onProgress, from, to }) { +export async function silenceDetect({ filePath, filterOptions, onProgress, from, to }) { function matchLineTokens(line) { const match = line.match(/^[silencedetect\s*@\s*0x[0-9a-f]+] silence_end: ([\d\\.]+)[|\s]+silence_duration: ([\d\\.]+)/); if (!match) return {}; @@ -697,7 +697,7 @@ export async function silenceDetect({ filePath, duration, filterOptions, onProgr }; } const customArgs = ['-af', `silencedetect=${mapFilterOptions(filterOptions)}`, '-vn']; - return detectIntervals({ filePath, duration, onProgress, from, to, matchLineTokens, customArgs }); + return detectIntervals({ filePath, onProgress, from, to, matchLineTokens, customArgs }); } export async function extractWaveform({ filePath, outPath }) { @@ -727,21 +727,46 @@ export async function extractWaveform({ filePath, outPath }) { console.timeEnd('ffmpeg'); } -// See also capture-frame.js -export async function captureFrames({ timestamp, videoPath, outPath, numFrames, quality }) { +function getFffmpegJpegQuality(quality) { // Normal range for JPEG is 2-31 with 31 being the worst quality. - const min = 2; - const max = 31; - const ffmpegQuality = Math.min(Math.max(min, quality, Math.round((1 - quality) * (max - min) + min)), max); + const qMin = 2; + const qMax = 31; + return Math.min(Math.max(qMin, quality, Math.round((1 - quality) * (qMax - qMin) + qMin)), qMax); +} + +export async function captureFrame({ timestamp, videoPath, outPath, quality }) { + const ffmpegQuality = getFffmpegJpegQuality(quality); await runFfmpeg([ '-ss', timestamp, '-i', videoPath, - '-vframes', numFrames, + '-vframes', '1', '-q:v', ffmpegQuality, '-y', outPath, ]); } +export async function captureFrames({ from, to, videoPath, outPathTemplate, quality, filter, onProgress }) { + const ffmpegQuality = getFffmpegJpegQuality(quality); + + const args = [ + '-ss', from, + '-i', videoPath, + '-t', Math.max(0, to - from), + '-q:v', ffmpegQuality, + ...(filter != null ? ['-vf', filter] : []), + // https://superuser.com/questions/1336285/use-ffmpeg-for-thumbnail-selections + // '-frame_pts', '1', // if we set this, output file name frame numbers will not start at 0 + '-vsync', '0', // else we get a ton of duplicates (thumbnail filter) + '-y', outPathTemplate, + ]; + + const process = runFfmpeg(args, { encoding: null, buffer: false }); + + handleProgress(process, to - from, onProgress); + + await process; +} + export function isIphoneHevc(format, streams) { if (!streams.some((s) => s.codec_name === 'hevc')) return false; const makeTag = format.tags && format.tags['com.apple.quicktime.make']; @@ -805,7 +830,7 @@ function createRawFfmpeg({ fps = 25, path, inWidth, inHeight, seekTo, oneFrameOn // console.log(args); return { - process: execa(getFfmpegPath(), args, execaOpts), + process: runFfmpeg(args, execaOpts, { logCli: false }), width: newWidth, height: newHeight, channels: 4, @@ -1012,5 +1037,5 @@ export async function cutEncodeSmartPart({ filePath, cutFrom, cutTo, outPath, ou const ffmpegCommandLine = getFfCommandLine('ffmpeg', ffmpegArgs); console.log(ffmpegCommandLine); - await execa(getFfmpegPath(), ffmpegArgs); + await runFfmpeg(ffmpegArgs); } diff --git a/src/hooks/useFfmpegOperations.js b/src/hooks/useFfmpegOperations.js index e4396736..23f6b038 100644 --- a/src/hooks/useFfmpegOperations.js +++ b/src/hooks/useFfmpegOperations.js @@ -4,11 +4,10 @@ import sum from 'lodash/sum'; import pMap from 'p-map'; import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath } from '../util'; -import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, RefuseOverwriteError } from '../ffmpeg'; +import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getDuration, runFfmpeg, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, RefuseOverwriteError } from '../ffmpeg'; import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams'; import { getSmartCutParams } from '../smartcut'; -const execa = window.require('execa'); const { join, resolve } = window.require('path'); const fs = window.require('fs-extra'); const stringToStream = window.require('string-to-stream'); @@ -155,8 +154,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) { console.log(fullCommandLine); appendFfmpegCommandLog(fullCommandLine); - const ffmpegPath = getFfmpegPath(); - const process = execa(ffmpegPath, ffmpegArgs); + const process = runFfmpeg(ffmpegArgs); handleProgress(process, totalDuration, onProgress); @@ -312,8 +310,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) { console.log(ffmpegCommandLine); appendFfmpegCommandLog(ffmpegCommandLine); - const ffmpegPath = getFfmpegPath(); - const process = execa(ffmpegPath, ffmpegArgs); + const process = runFfmpeg(ffmpegArgs); handleProgress(process, cutDuration, onProgress); const result = await process; console.log(result.stdout);