diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index f013b65d..2970dd12 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -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) => Object.entries(options).map(([key, value]) => `${key}=${value}`).join(':'); -export async function blackDetect({ filePath, filterOptions, onProgress, from, to }: { filePath: string, filterOptions: Record, 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, onProgress: (p: number) => void, from: number, to: number, +export async function blackDetect({ filePath, filterOptions, boundingMode, onProgress, from, to }: { + filePath: string, filterOptions: Record, 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, 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'], }); diff --git a/src/renderer/src/dialogs/parameters.tsx b/src/renderer/src/dialogs/parameters.tsx index 0b11189d..f18a3536 100644 --- a/src/renderer/src/dialogs/parameters.tsx +++ b/src/renderer/src/dialogs/parameters.tsx @@ -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: { parameters = newParameters; }} onSubmit={handleSubmit} docUrl={docUrl} />, + html: ( + { parameters = newParameters; }} + onSubmit={handleSubmit} + docUrl={docUrl} + /> + ), confirmButtonText: i18n.t('Confirm'), showCancelButton: true, cancelButtonText: i18n.t('Cancel'), diff --git a/src/renderer/src/ffmpeg-parameters.ts b/src/renderer/src/ffmpeg-parameters.ts index 5aea92b3..c4ad8f31 100644 --- a/src/renderer/src/ffmpeg-parameters.ts +++ b/src/renderer/src/ffmpeg-parameters.ts @@ -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 = () => ({ diff --git a/src/renderer/src/hooks/useSegments.ts b/src/renderer/src/hooks/useSegments.ts index 34370550..81aa83b1 100644 --- a/src/renderer/src/hooks/useSegments.ts +++ b/src/renderer/src/hooks/useSegments.ts @@ -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]);