1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 02:12:30 +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)] : []), ...(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 }[] = []; const segments: { start: number, end: number | undefined }[] = [];
for (let i = 0; i < times.length; i += 1) { for (let i = 0; i < times.length; i += 1) {
const start = times[i]; const start = times[i];
const end = times[i + 1]; 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; return segments;
} }
@ -289,13 +295,13 @@ export async function detectSceneChanges({ filePath, minChange, onProgress, from
await process; await process;
const segments = mapTimesToSegments(times); const segments = mapTimesToSegments(times, false);
return adjustSegmentsWithOffset({ segments, from }); return adjustSegmentsWithOffset({ segments, from });
} }
async function detectIntervals({ filePath, customArgs, onProgress, from, to, matchLineTokens }: { 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?: string | number | undefined, end?: string | number | undefined }, filePath: string, customArgs: string[], onProgress: (p: number) => void, from: number, to: number, matchLineTokens: (line: string) => { start: number, end: number } | undefined, boundingMode: boolean
}) { }) {
const args = [ const args = [
'-hide_banner', '-hide_banner',
@ -305,93 +311,96 @@ async function detectIntervals({ filePath, customArgs, onProgress, from, to, mat
]; ];
const process = runFfmpegProcess(args, { buffer: false }); 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) { function customMatcher(line: string) {
const { start: startRaw, end: endRaw } = matchLineTokens(line); const match = matchLineTokens(line);
if (typeof startRaw === 'number' && typeof endRaw === 'number') { if (match == null) return;
if (startRaw == null || endRaw == null) return; const { start, end } = match;
if (Number.isNaN(startRaw) || Number.isNaN(endRaw)) return;
segments.push({ start: startRaw, end: endRaw }); if (boundingMode) {
} 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;
segments.push({ start, end }); segments.push({ start, end });
} else { } else {
throw new TypeError('Invalid line match'); midpoints.push(start + ((end - start) / 2));
} }
} }
handleProgress(process, to - from, onProgress, customMatcher); handleProgress(process, to - from, onProgress, customMatcher);
await process; 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 }); return adjustSegmentsWithOffset({ segments, from });
} }
const mapFilterOptions = (options: Record<string, string>) => Object.entries(options).map(([key, value]) => `${key}=${value}`).join(':'); 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 }) { export async function blackDetect({ filePath, filterOptions, boundingMode, onProgress, from, to }: {
const customArgs = ['-vf', `blackdetect=${mapFilterOptions(filterOptions)}`, '-an']; filePath: string, filterOptions: Record<string, string>, boundingMode: boolean, onProgress: (p: number) => void, from: number, to: number,
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,
}) { }) {
return detectIntervals({ return detectIntervals({
filePath, filePath,
onProgress, onProgress,
from, from,
to, 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) => { matchLineTokens: (line) => {
// eslint-disable-next-line unicorn/better-regex // 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\\.]+)/); const match = line.match(/^[silencedetect\s*@\s*0x[0-9a-f]+] silence_end: ([\d\\.]+)[|\s]+silence_duration: ([\d\\.]+)/);
if (!match) { if (!match) {
return { return undefined;
start: undefined,
end: undefined,
};
} }
const end = parseFloat(match[1]!); const end = parseFloat(match[1]!);
const silenceDuration = parseFloat(match[2]!); const silenceDuration = parseFloat(match[2]!);
if (Number.isNaN(end) || Number.isNaN(silenceDuration)) { if (Number.isNaN(end) || Number.isNaN(silenceDuration)) {
return { return undefined;
start: undefined,
end: undefined,
};
} }
const start = end - silenceDuration; const start = end - silenceDuration;
if (start < 0 || end <= 0 || start >= end) { if (start < 0 || end <= 0 || start >= end) {
return { return undefined;
start: undefined,
end: undefined,
};
} }
return { return { start, end };
start,
end,
};
}, },
customArgs: ['-af', `silencedetect=${mapFilterOptions(filterOptions)}`, '-vn'], 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 parameters = parametersIn;
let resolve1: (value: boolean) => void; let resolve1: (value: boolean) => void;
@ -81,7 +83,15 @@ export async function showParametersDialog({ title, description, parameters: par
const promise2 = (async () => { const promise2 = (async () => {
const { isConfirmed } = await ReactSwal.fire({ const { isConfirmed } = await ReactSwal.fire({
title, 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'), confirmButtonText: i18n.t('Confirm'),
showCancelButton: true, showCancelButton: true,
cancelButtonText: i18n.t('Cancel'), cancelButtonText: i18n.t('Cancel'),

View File

@ -14,6 +14,10 @@ export const blackdetect = () => ({
value: '0.10', value: '0.10',
hint: i18n.t('Set the threshold for considering a pixel "black".'), 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 = () => ({ export const silencedetect = () => ({
@ -25,6 +29,10 @@ export const silencedetect = () => ({
value: '2.0', value: '2.0',
hint: i18n.t('Set minimum silence duration that will be converted into a segment.'), 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 = () => ({ 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 selectedSegmentsRaw = useMemo(() => apparentCutSegments.filter((segment) => isSegmentSelected(segment)), [apparentCutSegments, isSegmentSelected]);
const detectBlackScenes = useCallback(async () => { const detectBlackScenes = useCallback(async () => {
const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.blackdetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' }); const parameters = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.blackdetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' });
if (filterOptions == null) return; if (parameters == null) return;
const { mode, ...filterOptions } = parameters;
invariant(mode === '1' || mode === '2');
invariant(filePath != null); 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]); }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath, setCutProgress]);
const detectSilentScenes = useCallback(async () => { const detectSilentScenes = useCallback(async () => {
const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.silencedetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' }); const parameters = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.silencedetect(), docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' });
if (filterOptions == null) return; if (parameters == null) return;
const { mode, ...filterOptions } = parameters;
invariant(mode === '1' || mode === '2');
invariant(filePath != null); 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]); }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath, setCutProgress]);
const detectSceneChanges = useCallback(async () => { const detectSceneChanges = useCallback(async () => {
@ -157,7 +161,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
if (!videoStream) return; if (!videoStream) return;
invariant(filePath != null); invariant(filePath != null);
const keyframes = (await readFrames({ filePath, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end, streamIndex: videoStream.index })).filter((frame) => frame.keyframe); 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); loadCutSegments(newSegments, true);
}, [currentApparentCutSeg.end, currentApparentCutSeg.start, filePath, loadCutSegments, videoStream]); }, [currentApparentCutSeg.end, currentApparentCutSeg.start, filePath, loadCutSegments, videoStream]);