1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-25 11:43:17 +01:00

improvements

- Capture the best image every nth second
- Capture exactly one image every nth second #1139
- Capture exactly one image every nth frame #1139
- Capture frames that differ the most from the previous frame
- fix broken progress (duration)
- refactor
This commit is contained in:
Mikael Finstad 2023-01-06 20:10:57 +08:00
parent 2c76993953
commit 9332fad5a1
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
8 changed files with 202 additions and 78 deletions

View File

@ -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,
};

View File

@ -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) {

View File

@ -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]);

View File

@ -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 outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix });
await ffmpegCaptureFrames({ timestamp: fromTime, videoPath: filePath, outPath, numFrames, quality });
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;
}
if (enableTransferTimestamps && numFrames === 1) await transferTimestamps(filePath, outPath, fromTime);
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 ffmpegCaptureFrame({ timestamp: fromTime, videoPath: filePath, outPath, quality });
if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, fromTime);
return outPath;
}

View File

@ -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: {

View File

@ -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);

View File

@ -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);
}

View File

@ -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);