1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 10:22:31 +01:00

create segments from:

- scene changes
- keyframes

closes #1398

also limit max number of segments
and improve parameters dialog
This commit is contained in:
Mikael Finstad 2022-12-29 18:22:24 +08:00
parent 60bf1a5c5f
commit 64966590e2
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
9 changed files with 210 additions and 76 deletions

View File

@ -52,7 +52,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic
- MKV/MP4 embedded chapters marks editor - MKV/MP4 embedded chapters marks editor
- View subtitles - View subtitles
- Customizable keyboard hotkeys - Customizable keyboard hotkeys
- Black scene detection and silent audio detection - Black scene detection, silent audio detection, and scene change detection
- Divide timeline into segments with length L or into N segments or even randomized segments! - Divide timeline into segments with length L or into N segments or even randomized segments!
- [Basic CLI support](cli.md) - [Basic CLI support](cli.md)
@ -77,6 +77,8 @@ The main feature is lossless trimming and cutting of video and audio files, whic
- Loop a video / audio clip X times quickly without re-encoding - Loop a video / audio clip X times quickly without re-encoding
- See [#284](https://github.com/mifi/lossless-cut/issues/284) - See [#284](https://github.com/mifi/lossless-cut/issues/284)
- Convert a video or parts of it into X image files (not lossless) - Convert a video or parts of it into X image files (not lossless)
- Losslessly split a video into one file per scene (note you probably have to shift segments, see [#330](https://github.com/mifi/lossless-cut/issues/330).)
- Cut away silent parts of an audio/video
### Export cut times as YouTube Chapters ### Export cut times as YouTube Chapters
1. Export with Merge and "Create chapters from merged segments" enabled 1. Export with Merge and "Create chapters from merged segments" enabled

View File

@ -305,6 +305,18 @@ module.exports = (app, mainWindow, newVersion) => {
mainWindow.webContents.send('detectSilentScenes'); mainWindow.webContents.send('detectSilentScenes');
}, },
}, },
{
label: i18n.t('Detect scene changes'),
click() {
mainWindow.webContents.send('detectSceneChanges');
},
},
{
label: i18n.t('Create segments from keyframes'),
click() {
mainWindow.webContents.send('createSegmentsFromKeyframes');
},
},
{ {
label: i18n.t('Last ffmpeg commands'), label: i18n.t('Last ffmpeg commands'),
click() { mainWindow.webContents.send('toggleLastCommands'); }, click() { mainWindow.webContents.send('toggleLastCommands'); },

View File

@ -53,9 +53,9 @@ import {
getStreamFps, isCuttingStart, isCuttingEnd, getStreamFps, isCuttingStart, isCuttingEnd,
readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails, readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails,
extractStreams, runStartupCheck, setCustomFfPath as ffmpegSetCustomFfPath, extractStreams, runStartupCheck, setCustomFfPath as ffmpegSetCustomFfPath,
isIphoneHevc, tryMapChaptersToEdl, blackDetect, silenceDetect, isIphoneHevc, tryMapChaptersToEdl, blackDetect, silenceDetect, detectSceneChanges as ffmpegDetectSceneChanges,
getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack, getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack,
getFfmpegPath, RefuseOverwriteError, getFfmpegPath, RefuseOverwriteError, readFrames, mapTimesToSegments,
} from './ffmpeg'; } from './ffmpeg';
import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, doesPlayerSupportFile } from './util/streams'; import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, doesPlayerSupportFile } from './util/streams';
import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore'; import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore';
@ -74,6 +74,8 @@ import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n'; import { fallbackLng } from './i18n';
import { createSegment, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap } from './segments'; import { createSegment, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap } from './segments';
import { getOutSegError as getOutSegErrorRaw } from './util/outputNameTemplate'; import { getOutSegError as getOutSegErrorRaw } from './util/outputNameTemplate';
import * as ffmpegParameters from './ffmpeg-parameters';
import { maxSegmentsAllowed } from './util/constants';
import isDev from './isDev'; import isDev from './isDev';
@ -1491,13 +1493,14 @@ const App = memo(() => {
if (validEdl.length === 0) throw new Error(i18n.t('No valid segments found')); if (validEdl.length === 0) throw new Error(i18n.t('No valid segments found'));
if (!append) { if (!append) clearSegCounter();
clearSegCounter();
} if (validEdl.length > maxSegmentsAllowed) throw new Error(i18n.t('Tried to create too many segments (max {{maxSegmentsAllowed}}.)', { maxSegmentsAllowed }));
setCutSegments((existingSegments) => { setCutSegments((existingSegments) => {
const needToAppend = append && existingSegments.length > 1; const needToAppend = append && existingSegments.length > 1;
const newSegments = validEdl.map((segment, i) => createIndexedSegment({ segment, incrementCount: needToAppend || i > 0 })); let newSegments = validEdl.map((segment, i) => createIndexedSegment({ segment, incrementCount: needToAppend || i > 0 }));
if (needToAppend) return [...existingSegments, ...newSegments]; if (needToAppend) newSegments = [...existingSegments, ...newSegments];
return newSegments; return newSegments;
}); });
}, [clearSegCounter, createIndexedSegment, setCutSegments]); }, [clearSegCounter, createIndexedSegment, setCutSegments]);
@ -1753,7 +1756,7 @@ const App = memo(() => {
} }
}, [customOutDir, enableOverwriteOutput, filePath, mainStreams, setWorking]); }, [customOutDir, enableOverwriteOutput, filePath, mainStreams, setWorking]);
const detectScenes = useCallback(async ({ name, workingText, errorText, fn }) => { const detectSegments = useCallback(async ({ name, workingText, errorText, fn }) => {
if (!filePath) return; if (!filePath) return;
if (workingRef.current) return; if (workingRef.current) return;
try { try {
@ -1764,8 +1767,7 @@ const App = memo(() => {
console.log(name, newSegments); console.log(name, newSegments);
loadCutSegments(newSegments, true); loadCutSegments(newSegments, true);
} catch (err) { } catch (err) {
errorToast(errorText); handleError(errorText, err);
console.error('Failed to detect scenes', name, err);
} finally { } finally {
setWorking(); setWorking();
setCutProgress(); setCutProgress();
@ -1773,40 +1775,28 @@ const App = memo(() => {
}, [filePath, setWorking, loadCutSegments]); }, [filePath, setWorking, loadCutSegments]);
const detectBlackScenes = useCallback(async () => { const detectBlackScenes = useCallback(async () => {
const parameters = { const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.blackdetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' });
black_min_duration: {
value: '2.0',
hint: i18n.t('Set the minimum detected black duration expressed in seconds. It must be a non-negative floating point number.'),
},
picture_black_ratio_th: {
value: '0.98',
hint: i18n.t('Set the threshold for considering a picture "black".'),
},
pixel_black_th: {
value: '0.10',
hint: i18n.t('Set the threshold for considering a pixel "black".'),
},
};
const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters, docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' });
if (filterOptions == null) return; if (filterOptions == null) return;
await detectScenes({ 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 }) }); 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, detectScenes, duration, filePath]); }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, duration, filePath]);
const detectSilentScenes = useCallback(async () => { const detectSilentScenes = useCallback(async () => {
const parameters = { const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.silencedetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' });
noise: {
value: '-60dB',
hint: i18n.t('Set noise tolerance. Can be specified in dB (in case "dB" is appended to the specified value) or amplitude ratio. Default is -60dB, or 0.001.'),
},
duration: {
value: '2.0',
hint: i18n.t('Set silence duration until notification (default is 2 seconds).'),
},
};
const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters, docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' });
if (filterOptions == null) return; if (filterOptions == null) return;
await detectScenes({ 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 }) }); 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, detectScenes, duration, filePath]); }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, duration, 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]);
const createSegmentsFromKeyframes = useCallback(async () => {
const keyframes = (await readFrames({ filePath, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end, streamIndex: mainVideoStream?.index })).filter((frame) => frame.keyframe);
const newSegments = mapTimesToSegments(keyframes.map((keyframe) => keyframe.time));
loadCutSegments(newSegments, true);
}, [currentApparentCutSeg.end, currentApparentCutSeg.start, filePath, loadCutSegments, mainVideoStream?.index]);
const userHtml5ifyCurrentFile = useCallback(async () => { const userHtml5ifyCurrentFile = useCallback(async () => {
if (!filePath) return; if (!filePath) return;
@ -2276,7 +2266,7 @@ const App = memo(() => {
} }
} }
const action = { const actions = {
openFiles: (event, filePaths) => { userOpenFiles(filePaths.map(resolvePathIfNeeded)); }, openFiles: (event, filePaths) => { userOpenFiles(filePaths.map(resolvePathIfNeeded)); },
openFilesDialog, openFilesDialog,
closeCurrentFile: () => { closeFileWithConfirm(); }, closeCurrentFile: () => { closeFileWithConfirm(); },
@ -2305,13 +2295,25 @@ const App = memo(() => {
concatCurrentBatch, concatCurrentBatch,
detectBlackScenes, detectBlackScenes,
detectSilentScenes, detectSilentScenes,
detectSceneChanges,
createSegmentsFromKeyframes,
shiftAllSegmentTimes, shiftAllSegmentTimes,
}; };
const entries = Object.entries(action); const actionsWithCatch = Object.entries(actions).map(([key, action]) => [
entries.forEach(([key, value]) => electron.ipcRenderer.on(key, value)); key,
return () => entries.forEach(([key, value]) => electron.ipcRenderer.removeListener(key, value)); async () => {
}, [apparentCutSegments, askSetStartTimeOffset, checkFileOpened, clearSegments, closeBatch, closeFileWithConfirm, concatCurrentBatch, createFixedDurationSegments, createNumSegments, createRandomSegments, customOutDir, cutSegments, detectBlackScenes, detectSilentScenes, detectedFps, extractAllStreams, fileFormat, filePath, fillSegmentsGaps, getFrameCount, invertAllSegments, loadCutSegments, loadMedia, openFilesDialog, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shiftAllSegmentTimes, shuffleSegments, toggleKeyboardShortcuts, toggleLastCommands, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]); try {
await action();
} catch (err) {
handleError(err);
}
},
]);
actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.on(key, action));
return () => actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.removeListener(key, action));
}, [apparentCutSegments, askSetStartTimeOffset, checkFileOpened, clearSegments, closeBatch, closeFileWithConfirm, concatCurrentBatch, createFixedDurationSegments, createNumSegments, createRandomSegments, createSegmentsFromKeyframes, customOutDir, cutSegments, detectBlackScenes, detectSceneChanges, detectSilentScenes, detectedFps, extractAllStreams, fileFormat, filePath, fillSegmentsGaps, getFrameCount, invertAllSegments, loadCutSegments, loadMedia, openFilesDialog, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shiftAllSegmentTimes, shuffleSegments, toggleKeyboardShortcuts, toggleLastCommands, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]);
const showAddStreamSourceDialog = useCallback(async () => { const showAddStreamSourceDialog = useCallback(async () => {
try { try {

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Button, TextInputField, Checkbox, RadioGroup, Paragraph, LinkIcon } from 'evergreen-ui'; import { Button, TextInputField, Checkbox, RadioGroup, Paragraph, LinkIcon } from 'evergreen-ui';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import i18n from 'i18next'; import i18n from 'i18next';
@ -404,42 +404,71 @@ export async function showCleanupFilesDialog(cleanupChoicesIn = {}) {
return undefined; return undefined;
} }
const ParametersInput = ({ description, parameters: parametersIn, onChange: onChangeProp, docUrl }) => { const ParametersInput = ({ description, parameters: parametersIn, onChange, onSubmit, docUrl }) => {
const firstInputRef = useRef();
const [parameters, setParameters] = useState(parametersIn); const [parameters, setParameters] = useState(parametersIn);
const getParameter = (key) => parameters[key]?.value; const getParameter = (key) => parameters[key]?.value;
const onChange = (key, value) => setParameters((existing) => {
const handleChange = (key, value) => setParameters((existing) => {
const newParameters = { ...existing, [key]: { ...existing[key], value } }; const newParameters = { ...existing, [key]: { ...existing[key], value } };
onChangeProp(newParameters); onChange(newParameters);
return newParameters; return newParameters;
}); });
const handleSubmit = useCallback((e) => {
e.preventDefault();
onSubmit();
}, [onSubmit]);
useEffect(() => {
firstInputRef.current?.focus?.();
}, []);
return ( return (
<div style={{ textAlign: 'left' }}> <div style={{ textAlign: 'left' }}>
{description && <p>{description}</p>} {description && <p>{description}</p>}
{docUrl && <p><Button iconBefore={LinkIcon} onClick={() => electron.shell.openExternal(docUrl)}>Read more</Button></p>} {docUrl && <p><Button iconBefore={LinkIcon} onClick={() => electron.shell.openExternal(docUrl)}>Read more</Button></p>}
{Object.entries(parametersIn).map(([key, parameter]) => ( <form onSubmit={handleSubmit}>
<TextInputField key={key} label={parameter.label || key} value={getParameter(key)} onChange={(e) => onChange(key, e.target.value)} hint={parameter.hint} /> {Object.entries(parametersIn).map(([key, parameter], i) => (
))} <TextInputField ref={i === 0 ? firstInputRef : undefined} key={key} label={parameter.label || key} value={getParameter(key)} onChange={(e) => handleChange(key, e.target.value)} hint={parameter.hint} />
))}
<input type="submit" value="submit" style={{ display: 'none' }} />
</form>
</div> </div>
); );
}; };
export async function showParametersDialog({ title, description, parameters: parametersIn, docUrl }) { export async function showParametersDialog({ title, description, parameters: parametersIn, docUrl }) {
let parameters = parametersIn; let parameters = parametersIn;
let resolve1;
const { value } = await ReactSwal.fire({ const promise1 = new Promise((resolve) => {
title, resolve1 = resolve;
html: <ParametersInput description={description} parameters={parameters} onChange={(newParameters) => { parameters = newParameters; }} docUrl={docUrl} />,
confirmButtonText: i18n.t('Confirm'),
showCancelButton: true,
cancelButtonText: i18n.t('Cancel'),
}); });
const handleSubmit = () => {
Swal.close();
resolve1(true);
};
if (value) return Object.fromEntries(Object.entries(parameters).map(([key, parameter]) => [key, parameter.value])); const promise2 = (async () => {
return undefined; const { isConfirmed } = await ReactSwal.fire({
title,
html: <ParametersInput description={description} parameters={parameters} onChange={(newParameters) => { parameters = newParameters; }} onSubmit={handleSubmit} docUrl={docUrl} />,
confirmButtonText: i18n.t('Confirm'),
showCancelButton: true,
cancelButtonText: i18n.t('Cancel'),
});
return isConfirmed;
})();
const isConfirmed = await Promise.race([promise1, promise2]);
if (!isConfirmed) return undefined;
return Object.fromEntries(Object.entries(parameters).map(([key, parameter]) => [key, parameter.value]));
} }

34
src/ffmpeg-parameters.js Normal file
View File

@ -0,0 +1,34 @@
import i18n from 'i18next';
export const blackdetect = () => ({
black_min_duration: {
value: '2.0',
hint: i18n.t('Set the minimum detected black duration expressed in seconds. It must be a non-negative floating point number.'),
},
picture_black_ratio_th: {
value: '0.98',
hint: i18n.t('Set the threshold for considering a picture "black".'),
},
pixel_black_th: {
value: '0.10',
hint: i18n.t('Set the threshold for considering a pixel "black".'),
},
});
export const silencedetect = () => ({
noise: {
value: '-60dB',
hint: i18n.t('Set noise tolerance. Can be specified in dB (in case "dB" is appended to the specified value) or amplitude ratio. Default is -60dB, or 0.001.'),
},
duration: {
value: '2.0',
hint: i18n.t('Set silence duration until notification (default is 2 seconds).'),
},
});
export const sceneChange = () => ({
minChange: {
value: '0.3',
hint: i18n.t('Minimum change between two frames to be considered a new scene. A value between 0.3 and 0.5 is generally a sane choice.'),
},
});

View File

@ -108,12 +108,8 @@ function getIntervalAroundTime(time, window) {
}; };
} }
export async function readFrames({ filePath, aroundTime, window, streamIndex }) { export async function readFrames({ filePath, from, to, streamIndex }) {
let intervalsArgs = []; const intervalsArgs = from != null && to != null ? ['-read_intervals', `${from}%${to}`] : [];
if (aroundTime != null) {
const { from, to } = getIntervalAroundTime(aroundTime, window);
intervalsArgs = ['-read_intervals', `${from}%${to}`];
}
const { stdout } = await runFfprobe(['-v', 'error', ...intervalsArgs, '-show_packets', '-select_streams', streamIndex, '-show_entries', 'packet=pts_time,flags', '-of', 'json', filePath]); const { stdout } = await runFfprobe(['-v', 'error', ...intervalsArgs, '-show_packets', '-select_streams', streamIndex, '-show_entries', 'packet=pts_time,flags', '-of', 'json', filePath]);
const packetsFiltered = JSON.parse(stdout).packets const packetsFiltered = JSON.parse(stdout).packets
.map(p => ({ .map(p => ({
@ -126,6 +122,12 @@ export async function readFrames({ filePath, aroundTime, window, streamIndex })
return sortBy(packetsFiltered, 'time'); return sortBy(packetsFiltered, 'time');
} }
export async function readFramesAroundTime({ filePath, streamIndex, aroundTime, window }) {
if (aroundTime == null) throw new Error('aroundTime was nullish');
const { from, to } = getIntervalAroundTime(aroundTime, window);
return readFrames({ filePath, from, to, streamIndex });
}
// https://stackoverflow.com/questions/14005110/how-to-split-a-video-using-ffmpeg-so-that-each-chunk-starts-with-a-key-frame // https://stackoverflow.com/questions/14005110/how-to-split-a-video-using-ffmpeg-so-that-each-chunk-starts-with-a-key-frame
// http://kicherer.org/joomla/index.php/de/blog/42-avcut-frame-accurate-video-cutting-with-only-small-quality-loss // http://kicherer.org/joomla/index.php/de/blog/42-avcut-frame-accurate-video-cutting-with-only-small-quality-loss
export function getSafeCutTime(frames, cutTime, nextMode) { export function getSafeCutTime(frames, cutTime, nextMode) {
@ -548,12 +550,63 @@ export async function renderWaveformPng({ filePath, aroundTime, window, color })
} }
} }
const getInputSeekArgs = ({ filePath, from, to }) => [
...(from != null ? ['-ss', from.toFixed(5)] : []),
'-i', filePath,
...(to != null ? ['-t', (to - from).toFixed(5)] : []),
];
const getSegmentOffset = (from) => (from != null ? from : 0);
function adjustSegmentsWithOffset({ segments, from }) {
const offset = getSegmentOffset(from);
return segments.map(({ start, end }) => ({ start: start + offset, end: end != null ? end + offset : end }));
}
export function mapTimesToSegments(times) {
const segments = [];
for (let i = 0; i < times.length; i += 1) {
const start = times[i];
const end = times[i + 1];
if (start != null) segments.push({ start, end }); // end undefined is allowed (means until end of video)
}
return segments;
}
// 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 }) {
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 times = [0];
handleProgress(process, duration, 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.]+)/);
if (!match) return;
const time = parseFloat(match[1]);
if (Number.isNaN(time) || time <= times[times.length - 1]) return;
times.push(time);
});
await process;
const segments = mapTimesToSegments(times);
return adjustSegmentsWithOffset({ segments, from });
}
export async function detectIntervals({ filePath, duration, customArgs, onProgress, from, to, matchLineTokens }) { export async function detectIntervals({ filePath, duration, customArgs, onProgress, from, to, matchLineTokens }) {
const args = [ const args = [
'-hide_banner', '-hide_banner',
...(from != null ? ['-ss', from.toFixed(5)] : []), ...getInputSeekArgs({ filePath, from, to }),
'-i', filePath,
...(to != null ? ['-t', (to - from).toFixed(5)] : []),
...customArgs, ...customArgs,
'-f', 'null', '-', '-f', 'null', '-',
]; ];
@ -571,8 +624,7 @@ export async function detectIntervals({ filePath, duration, customArgs, onProgre
handleProgress(process, duration, onProgress, customMatcher); handleProgress(process, duration, onProgress, customMatcher);
await process; await process;
const offset = from != null ? from : 0; return adjustSegmentsWithOffset({ segments, from });
return segments.map(({ start, end }) => ({ start: start + offset, end: end + offset }));
} }
const mapFilterOptions = (options) => Object.entries(options).map(([key, value]) => `${key}=${value}`).join(':'); const mapFilterOptions = (options) => Object.entries(options).map(([key, value]) => `${key}=${value}`).join(':');

View File

@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this
import { readFrames, findNearestKeyFrameTime as ffmpegFindNearestKeyFrameTime } from '../ffmpeg'; import { readFramesAroundTime, findNearestKeyFrameTime as ffmpegFindNearestKeyFrameTime } from '../ffmpeg';
const maxKeyframes = 1000; const maxKeyframes = 1000;
// const maxKeyframes = 100; // const maxKeyframes = 100;
@ -26,7 +26,7 @@ export default ({ keyframesEnabled, filePath, commandedTime, mainVideoStream, de
if (!shouldRun) return; if (!shouldRun) return;
try { try {
const promise = readFrames({ filePath, aroundTime: commandedTime, streamIndex: mainVideoStream.index, window: ffmpegExtractWindow }); const promise = readFramesAroundTime({ filePath, aroundTime: commandedTime, streamIndex: mainVideoStream.index, window: ffmpegExtractWindow });
readingKeyframesPromise.current = promise; readingKeyframesPromise.current = promise;
const newFrames = await promise; const newFrames = await promise;
if (aborted) return; if (aborted) return;

View File

@ -1,6 +1,6 @@
import { getRealVideoStreams, getVideoTimebase } from './util/streams'; import { getRealVideoStreams, getVideoTimebase } from './util/streams';
import { readFrames } from './ffmpeg'; import { readFramesAroundTime } from './ffmpeg';
const { stat } = window.require('fs-extra'); const { stat } = window.require('fs-extra');
@ -18,7 +18,7 @@ export async function getSmartCutParams({ path, videoDuration, desiredCutFrom, s
const videoStream = videoStreams[0]; const videoStream = videoStreams[0];
async function readKeyframes(window) { async function readKeyframes(window) {
const frames = await readFrames({ filePath: path, aroundTime: desiredCutFrom, streamIndex: videoStream.index, window }); const frames = await readFramesAroundTime({ filePath: path, aroundTime: desiredCutFrom, streamIndex: videoStream.index, window });
return frames.filter((frame) => frame.keyframe); return frames.filter((frame) => frame.keyframe);
} }

3
src/util/constants.js Normal file
View File

@ -0,0 +1,3 @@
// anything more than this will probably cause the UI to become unusably slow
// eslint-disable-next-line import/prefer-default-export
export const maxSegmentsAllowed = 2000;