mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-21 18:02:35 +01:00
implement black/silent detect modes
https://github.com/mifi/lossless-cut/discussions/2077#discussioncomment-10246862 also simplify detectIntervals and matchLineTokens
This commit is contained in:
parent
c2e504ccc6
commit
36fb4dbc50
@ -243,12 +243,18 @@ const getInputSeekArgs = ({ filePath, from, to }: { filePath: string, from?: num
|
||||
...(from != null && to != null ? ['-t', (to - from).toFixed(5)] : []),
|
||||
];
|
||||
|
||||
export function mapTimesToSegments(times: number[]) {
|
||||
export function mapTimesToSegments(times: number[], includeLast: boolean) {
|
||||
const segments: { start: number, end: number | undefined }[] = [];
|
||||
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)
|
||||
if (start != null) {
|
||||
if (end != null) {
|
||||
segments.push({ start, end });
|
||||
} else if (includeLast) {
|
||||
segments.push({ start, end }); // end undefined is allowed (means until end of video)
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
@ -289,13 +295,13 @@ export async function detectSceneChanges({ filePath, minChange, onProgress, from
|
||||
|
||||
await process;
|
||||
|
||||
const segments = mapTimesToSegments(times);
|
||||
const segments = mapTimesToSegments(times, false);
|
||||
|
||||
return adjustSegmentsWithOffset({ segments, from });
|
||||
}
|
||||
|
||||
async function detectIntervals({ filePath, customArgs, onProgress, from, to, matchLineTokens }: {
|
||||
filePath: string, customArgs: string[], onProgress: (p: number) => void, from: number, to: number, matchLineTokens: (line: string) => { start?: string | number | undefined, end?: string | number | undefined },
|
||||
async function detectIntervals({ filePath, customArgs, onProgress, from, to, matchLineTokens, boundingMode }: {
|
||||
filePath: string, customArgs: string[], onProgress: (p: number) => void, from: number, to: number, matchLineTokens: (line: string) => { start: number, end: number } | undefined, boundingMode: boolean
|
||||
}) {
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
@ -305,93 +311,96 @@ async function detectIntervals({ filePath, customArgs, onProgress, from, to, mat
|
||||
];
|
||||
const process = runFfmpegProcess(args, { buffer: false });
|
||||
|
||||
const segments: { start: number, end: number }[] = [];
|
||||
let segments: { start: number, end: number }[] = [];
|
||||
const midpoints: number[] = [];
|
||||
|
||||
function customMatcher(line: string) {
|
||||
const { start: startRaw, end: endRaw } = matchLineTokens(line);
|
||||
if (typeof startRaw === 'number' && typeof endRaw === 'number') {
|
||||
if (startRaw == null || endRaw == null) return;
|
||||
if (Number.isNaN(startRaw) || Number.isNaN(endRaw)) return;
|
||||
segments.push({ start: startRaw, end: endRaw });
|
||||
} else if (typeof startRaw === 'string' && typeof endRaw === 'string') {
|
||||
if (startRaw == null || endRaw == null) return;
|
||||
const start = parseFloat(startRaw);
|
||||
const end = parseFloat(endRaw);
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return;
|
||||
const match = matchLineTokens(line);
|
||||
if (match == null) return;
|
||||
const { start, end } = match;
|
||||
|
||||
if (boundingMode) {
|
||||
segments.push({ start, end });
|
||||
} else {
|
||||
throw new TypeError('Invalid line match');
|
||||
midpoints.push(start + ((end - start) / 2));
|
||||
}
|
||||
}
|
||||
handleProgress(process, to - from, onProgress, customMatcher);
|
||||
|
||||
await process;
|
||||
|
||||
if (!boundingMode) {
|
||||
segments = midpoints.flatMap((time, i) => [
|
||||
{
|
||||
start: midpoints[i - 1] ?? 0,
|
||||
end: time,
|
||||
},
|
||||
{
|
||||
start: time,
|
||||
end: midpoints[i + 1] ?? (to - from),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return adjustSegmentsWithOffset({ segments, from });
|
||||
}
|
||||
|
||||
const mapFilterOptions = (options: Record<string, string>) => Object.entries(options).map(([key, value]) => `${key}=${value}`).join(':');
|
||||
|
||||
export async function blackDetect({ filePath, filterOptions, onProgress, from, to }: { filePath: string, filterOptions: Record<string, string>, onProgress: (p: number) => void, from: number, to: number }) {
|
||||
const customArgs = ['-vf', `blackdetect=${mapFilterOptions(filterOptions)}`, '-an'];
|
||||
return detectIntervals({
|
||||
filePath,
|
||||
onProgress,
|
||||
from,
|
||||
to,
|
||||
matchLineTokens: (line) => {
|
||||
// eslint-disable-next-line unicorn/better-regex
|
||||
const match = line.match(/^[blackdetect\s*@\s*0x[0-9a-f]+] black_start:([\d\\.]+) black_end:([\d\\.]+) black_duration:[\d\\.]+/);
|
||||
if (!match) {
|
||||
return {
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
start: match[1],
|
||||
end: match[2],
|
||||
};
|
||||
},
|
||||
customArgs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function silenceDetect({ filePath, filterOptions, onProgress, from, to }: {
|
||||
filePath: string, filterOptions: Record<string, string>, onProgress: (p: number) => void, from: number, to: number,
|
||||
export async function blackDetect({ filePath, filterOptions, boundingMode, onProgress, from, to }: {
|
||||
filePath: string, filterOptions: Record<string, string>, boundingMode: boolean, onProgress: (p: number) => void, from: number, to: number,
|
||||
}) {
|
||||
return detectIntervals({
|
||||
filePath,
|
||||
onProgress,
|
||||
from,
|
||||
to,
|
||||
boundingMode,
|
||||
matchLineTokens: (line) => {
|
||||
// eslint-disable-next-line unicorn/better-regex
|
||||
const match = line.match(/^[blackdetect\s*@\s*0x[0-9a-f]+] black_start:([\d\\.]+) black_end:([\d\\.]+) black_duration:[\d\\.]+/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const start = parseFloat(match[1]!);
|
||||
const end = parseFloat(match[2]!);
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) {
|
||||
return undefined;
|
||||
}
|
||||
if (start < 0 || end <= 0 || start >= end) {
|
||||
return undefined;
|
||||
}
|
||||
return { start, end };
|
||||
},
|
||||
customArgs: ['-vf', `blackdetect=${mapFilterOptions(filterOptions)}`, '-an'],
|
||||
});
|
||||
}
|
||||
|
||||
export async function silenceDetect({ filePath, filterOptions, boundingMode, onProgress, from, to }: {
|
||||
filePath: string, filterOptions: Record<string, string>, boundingMode: boolean, onProgress: (p: number) => void, from: number, to: number,
|
||||
}) {
|
||||
return detectIntervals({
|
||||
filePath,
|
||||
onProgress,
|
||||
from,
|
||||
to,
|
||||
boundingMode,
|
||||
matchLineTokens: (line) => {
|
||||
// eslint-disable-next-line unicorn/better-regex
|
||||
const match = line.match(/^[silencedetect\s*@\s*0x[0-9a-f]+] silence_end: ([\d\\.]+)[|\s]+silence_duration: ([\d\\.]+)/);
|
||||
if (!match) {
|
||||
return {
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
const end = parseFloat(match[1]!);
|
||||
const silenceDuration = parseFloat(match[2]!);
|
||||
if (Number.isNaN(end) || Number.isNaN(silenceDuration)) {
|
||||
return {
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
const start = end - silenceDuration;
|
||||
if (start < 0 || end <= 0 || start >= end) {
|
||||
return {
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
};
|
||||
return { start, end };
|
||||
},
|
||||
customArgs: ['-af', `silencedetect=${mapFilterOptions(filterOptions)}`, '-vn'],
|
||||
});
|
||||
|
@ -66,7 +66,9 @@ const ParametersInput = ({ description, parameters: parametersIn, onChange, onSu
|
||||
);
|
||||
};
|
||||
|
||||
export async function showParametersDialog({ title, description, parameters: parametersIn, docUrl }: { title?: string, description?: string, parameters: ParameterDialogParameters, docUrl?: string }) {
|
||||
export async function showParametersDialog({ title, description, parameters: parametersIn, docUrl }: {
|
||||
title?: string, description?: string, parameters: ParameterDialogParameters, docUrl?: string,
|
||||
}) {
|
||||
let parameters = parametersIn;
|
||||
let resolve1: (value: boolean) => void;
|
||||
|
||||
@ -81,7 +83,15 @@ export async function showParametersDialog({ title, description, parameters: par
|
||||
const promise2 = (async () => {
|
||||
const { isConfirmed } = await ReactSwal.fire({
|
||||
title,
|
||||
html: <ParametersInput description={description} parameters={parameters} onChange={(newParameters) => { parameters = newParameters; }} onSubmit={handleSubmit} docUrl={docUrl} />,
|
||||
html: (
|
||||
<ParametersInput
|
||||
description={description}
|
||||
parameters={parameters}
|
||||
onChange={(newParameters) => { parameters = newParameters; }}
|
||||
onSubmit={handleSubmit}
|
||||
docUrl={docUrl}
|
||||
/>
|
||||
),
|
||||
confirmButtonText: i18n.t('Confirm'),
|
||||
showCancelButton: true,
|
||||
cancelButtonText: i18n.t('Cancel'),
|
||||
|
@ -14,6 +14,10 @@ export const blackdetect = () => ({
|
||||
value: '0.10',
|
||||
hint: i18n.t('Set the threshold for considering a pixel "black".'),
|
||||
},
|
||||
mode: {
|
||||
value: '1',
|
||||
hint: i18n.t('Segment mode: "{{mode1}}" will create segments bounding the black sections. "{{mode2}}" will create segments that start/stop at the center of each black section.', { mode1: '1', mode2: '2' }),
|
||||
},
|
||||
});
|
||||
|
||||
export const silencedetect = () => ({
|
||||
@ -25,6 +29,10 @@ export const silencedetect = () => ({
|
||||
value: '2.0',
|
||||
hint: i18n.t('Set minimum silence duration that will be converted into a segment.'),
|
||||
},
|
||||
mode: {
|
||||
value: '1',
|
||||
hint: i18n.t('Segment mode: "{{mode1}}" will create segments bounding the silent sections. "{{mode2}}" will create segments that start/stop at the center of each silent section.', { mode1: '1', mode2: '2' }),
|
||||
},
|
||||
});
|
||||
|
||||
export const sceneChange = () => ({
|
||||
|
@ -130,17 +130,21 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
|
||||
const selectedSegmentsRaw = useMemo(() => apparentCutSegments.filter((segment) => isSegmentSelected(segment)), [apparentCutSegments, isSegmentSelected]);
|
||||
|
||||
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;
|
||||
const parameters = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.blackdetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' });
|
||||
if (parameters == null) return;
|
||||
const { mode, ...filterOptions } = parameters;
|
||||
invariant(mode === '1' || mode === '2');
|
||||
invariant(filePath != null);
|
||||
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 }) });
|
||||
await detectSegments({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async () => blackDetect({ filePath, filterOptions, boundingMode: mode === '1', onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) });
|
||||
}, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath, setCutProgress]);
|
||||
|
||||
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;
|
||||
const parameters = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.silencedetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' });
|
||||
if (parameters == null) return;
|
||||
const { mode, ...filterOptions } = parameters;
|
||||
invariant(mode === '1' || mode === '2');
|
||||
invariant(filePath != null);
|
||||
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 }) });
|
||||
await detectSegments({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async () => silenceDetect({ filePath, filterOptions, boundingMode: mode === '1', onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) });
|
||||
}, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath, setCutProgress]);
|
||||
|
||||
const detectSceneChanges = useCallback(async () => {
|
||||
@ -157,7 +161,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
|
||||
if (!videoStream) return;
|
||||
invariant(filePath != null);
|
||||
const keyframes = (await readFrames({ filePath, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end, streamIndex: videoStream.index })).filter((frame) => frame.keyframe);
|
||||
const newSegments = mapTimesToSegments(keyframes.map((keyframe) => keyframe.time));
|
||||
const newSegments = mapTimesToSegments(keyframes.map((keyframe) => keyframe.time), true);
|
||||
loadCutSegments(newSegments, true);
|
||||
}, [currentApparentCutSeg.end, currentApparentCutSeg.start, filePath, loadCutSegments, videoStream]);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user