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:
parent
2c76993953
commit
9332fad5a1
@ -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,
|
||||
};
|
||||
|
||||
|
38
src/App.jsx
38
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) {
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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: {
|
||||
|
110
src/dialogs.jsx
110
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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user