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)] : []),
|
...(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'],
|
||||||
});
|
});
|
||||||
|
@ -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'),
|
||||||
|
@ -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 = () => ({
|
||||||
|
@ -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]);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user