1
0
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:
Mikael Finstad 2024-08-16 22:04:33 +02:00
parent c2e504ccc6
commit 36fb4dbc50
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
4 changed files with 100 additions and 69 deletions

View File

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

View File

@ -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'),

View File

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

View File

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