From 97dda50ab08b1c792401c4f70cb3aae5844326a6 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Fri, 18 Mar 2022 17:51:24 +0800 Subject: [PATCH] implement black scene detection closes #623 --- README.md | 1 + public/menu.js | 7 +++++++ src/App.jsx | 24 ++++++++++++++++++++++-- src/ffmpeg.js | 27 +++++++++++++++++++++++++-- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ffb9622d..62e864d8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic - MKV/MP4 embedded chapters marks editor - View subtitles - Customizable keyboard hotkeys +- Black scene detection ## Example lossless use cases diff --git a/public/menu.js b/public/menu.js index 44c52475..9614ff82 100644 --- a/public/menu.js +++ b/public/menu.js @@ -278,6 +278,13 @@ module.exports = (app, mainWindow, newVersion) => { mainWindow.webContents.send('askSetStartTimeOffset'); }, }, + { + label: i18n.t('Detect black scenes'), + click() { + mainWindow.webContents.send('detectBlackScenes'); + }, + }, + { type: 'separator' }, { role: 'toggleDevTools', label: i18n.t('Toggle Developer Tools') }, ], }, diff --git a/src/App.jsx b/src/App.jsx index 4d68f562..e7d0ee54 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -54,7 +54,7 @@ import { getStreamFps, isCuttingStart, isCuttingEnd, readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails, extractStreams, runStartupCheck, setCustomFfPath as ffmpegSetCustomFfPath, - isIphoneHevc, tryMapChaptersToEdl, + isIphoneHevc, tryMapChaptersToEdl, blackDetect, getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack, } from './ffmpeg'; import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, defaultProcessedCodecTypes, isAudioDefinitelyNotSupported, doesPlayerSupportFile } from './util/streams'; @@ -1736,6 +1736,25 @@ const App = memo(() => { } }, [customOutDir, filePath, mainStreams, outputDir, setWorking]); + const detectBlackScenes = useCallback(async () => { + if (!filePath) return; + if (workingRef.current) return; + try { + setWorking(i18n.t('Detecting black scenes')); + setCutProgress(0); + + const blackSegments = await blackDetect({ filePath, duration, onProgress: setCutProgress }); + console.log('blackSegments', blackSegments); + loadCutSegments(blackSegments.map(({ blackStart, blackEnd }) => ({ start: blackStart, end: blackEnd }))); + } catch (err) { + errorToast(i18n.t('Failed to detect black scenes')); + console.error('Failed to detect black scenes', err); + } finally { + setWorking(); + setCutProgress(); + } + }, [duration, filePath, setWorking, loadCutSegments]); + const userHtml5ifyCurrentFile = useCallback(async () => { if (!filePath) return; @@ -2177,13 +2196,14 @@ const App = memo(() => { fixInvalidDuration: tryFixInvalidDuration, reorderSegsByStartTime, concatCurrentBatch, + detectBlackScenes, shiftAllSegmentTimes, }; const entries = Object.entries(action); entries.forEach(([key, value]) => electron.ipcRenderer.on(key, value)); return () => entries.forEach(([key, value]) => electron.ipcRenderer.removeListener(key, value)); - }, [apparentCutSegments, askSetStartTimeOffset, checkFileOpened, clearSegments, closeBatch, closeFileWithConfirm, concatCurrentBatch, createFixedDurationSegments, createNumSegments, customOutDir, cutSegments, detectedFps, extractAllStreams, fileFormat, filePath, fillSegmentsGaps, getFrameCount, invertAllSegments, loadCutSegments, loadMedia, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shiftAllSegmentTimes, shuffleSegments, toggleHelp, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]); + }, [apparentCutSegments, askSetStartTimeOffset, checkFileOpened, clearSegments, closeBatch, closeFileWithConfirm, concatCurrentBatch, createFixedDurationSegments, createNumSegments, customOutDir, cutSegments, detectBlackScenes, detectedFps, extractAllStreams, fileFormat, filePath, fillSegmentsGaps, getFrameCount, invertAllSegments, loadCutSegments, loadMedia, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shiftAllSegmentTimes, shuffleSegments, toggleHelp, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]); const showAddStreamSourceDialog = useCallback(async () => { try { diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 65272afd..c46c895a 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -60,7 +60,7 @@ export function runFfmpeg(args) { } -export function handleProgress(process, cutDuration, onProgress) { +export function handleProgress(process, cutDuration, onProgress, customMatcher = () => {}) { if (!onProgress) return; onProgress(0); @@ -72,7 +72,10 @@ export function handleProgress(process, cutDuration, onProgress) { let match = line.match(/frame=\s*[^\s]+\s+fps=\s*[^\s]+\s+q=\s*[^\s]+\s+(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/); // Audio only looks like this: "line size= 233422kB time=01:45:50.68 bitrate= 301.1kbits/s speed= 353x " if (!match) match = line.match(/(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/); - if (!match) return; + if (!match) { + customMatcher(line); + return; + } const str = match[1]; // console.log(str); @@ -514,6 +517,26 @@ export async function renderWaveformPng({ filePath, aroundTime, window, color }) } } +export async function blackDetect({ filePath, duration, minInterval = 0.05, onProgress }) { + const args = ['-hide_banner', '-i', filePath, '-vf', `blackdetect=d=${minInterval}`, '-an', '-f', 'null', '-']; + const process = execa(getFfmpegPath(), args, { encoding: null, buffer: false }); + + const blackSegments = []; + + function customMatcher(line) { + const match = line.match(/^[blackdetect @ 0x[0-9a-f]+] black_start:([\d\\.]+) black_end:([\d\\.]+) black_duration:[\d\\.]+/); + if (!match) return; + const blackStart = parseFloat(match[1]); + const blackEnd = parseFloat(match[2]); + if (Number.isNaN(blackStart) || Number.isNaN(blackEnd)) return; + blackSegments.push({ blackStart, blackEnd }); + } + handleProgress(process, duration, onProgress, customMatcher); + + await process; + return blackSegments; +} + export async function extractWaveform({ filePath, outPath }) { const numSegs = 10; const duration = 60 * 60;