diff --git a/README.md b/README.md index d8db8a62..dd058055 100644 --- a/README.md +++ b/README.md @@ -52,7 +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 +- Black scene detection and silent audio detection - Divide timeline into segments with length L or into N segments or even randomized segments! - [Basic CLI support](cli.md) diff --git a/public/menu.js b/public/menu.js index 80a25902..579b3b71 100644 --- a/public/menu.js +++ b/public/menu.js @@ -299,6 +299,12 @@ module.exports = (app, mainWindow, newVersion) => { mainWindow.webContents.send('detectBlackScenes'); }, }, + { + label: i18n.t('Detect silent scenes'), + click() { + mainWindow.webContents.send('detectSilentScenes'); + }, + }, { label: i18n.t('Last ffmpeg commands'), click() { mainWindow.webContents.send('toggleLastCommands'); }, diff --git a/src/App.jsx b/src/App.jsx index aa977376..a12ac418 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -53,7 +53,7 @@ import { getStreamFps, isCuttingStart, isCuttingEnd, readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails, extractStreams, runStartupCheck, setCustomFfPath as ffmpegSetCustomFfPath, - isIphoneHevc, tryMapChaptersToEdl, blackDetect, + isIphoneHevc, tryMapChaptersToEdl, blackDetect, silenceDetect, getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack, getFfmpegPath, RefuseOverwriteError, } from './ffmpeg'; @@ -69,7 +69,7 @@ import { } from './util'; import { formatDuration } from './util/duration'; import { adjustRate } from './util/rate-calculator'; -import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog, askForShiftSegments, selectSegmentsByLabelDialog, confirmExtractFramesAsImages, showRefuseToOverwrite } from './dialogs'; +import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog, askForShiftSegments, selectSegmentsByLabelDialog, confirmExtractFramesAsImages, showRefuseToOverwrite, showParametersDialog } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; import { createSegment, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap } from './segments'; @@ -1753,24 +1753,60 @@ const App = memo(() => { } }, [customOutDir, enableOverwriteOutput, filePath, mainStreams, setWorking]); - const detectBlackScenes = useCallback(async () => { + const detectScenes = useCallback(async ({ name, workingText, errorText, fn }) => { if (!filePath) return; if (workingRef.current) return; try { - setWorking(i18n.t('Detecting black scenes')); + setWorking(workingText); setCutProgress(0); - const blackSegments = await blackDetect({ filePath, duration, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }); - console.log('blackSegments', blackSegments); - loadCutSegments(blackSegments.map(({ blackStart, blackEnd }) => ({ start: blackStart, end: blackEnd })), true); + const newSegments = await fn(); + console.log(name, newSegments); + loadCutSegments(newSegments, true); } catch (err) { - errorToast(i18n.t('Failed to detect black scenes')); - console.error('Failed to detect black scenes', err); + errorToast(errorText); + console.error('Failed to detect scenes', name, err); } finally { setWorking(); setCutProgress(); } - }, [filePath, setWorking, duration, currentApparentCutSeg, loadCutSegments]); + }, [filePath, setWorking, loadCutSegments]); + + const detectBlackScenes = useCallback(async () => { + const parameters = { + black_min_duration: { + value: '2.0', + hint: i18n.t('Set the minimum detected black duration expressed in seconds. It must be a non-negative floating point number.'), + }, + picture_black_ratio_th: { + value: '0.98', + hint: i18n.t('Set the threshold for considering a picture "black".'), + }, + pixel_black_th: { + value: '0.10', + hint: i18n.t('Set the threshold for considering a pixel "black".'), + }, + }; + const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters, docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#blackdetect' }); + if (filterOptions == null) return; + await detectScenes({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async () => blackDetect({ filePath, duration, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); + }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectScenes, duration, filePath]); + + const detectSilentScenes = useCallback(async () => { + const parameters = { + noise: { + value: '-60dB', + hint: i18n.t('Set noise tolerance. Can be specified in dB (in case "dB" is appended to the specified value) or amplitude ratio. Default is -60dB, or 0.001.'), + }, + duration: { + value: '2.0', + hint: i18n.t('Set silence duration until notification (default is 2 seconds).'), + }, + }; + const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters, docUrl: 'https://ffmpeg.org/ffmpeg-filters.html#silencedetect' }); + if (filterOptions == null) return; + await detectScenes({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async () => silenceDetect({ filePath, duration, filterOptions, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); + }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectScenes, duration, filePath]); const userHtml5ifyCurrentFile = useCallback(async () => { if (!filePath) return; @@ -2268,13 +2304,14 @@ const App = memo(() => { reorderSegsByStartTime, concatCurrentBatch, detectBlackScenes, + detectSilentScenes, 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, createRandomSegments, customOutDir, cutSegments, detectBlackScenes, detectedFps, extractAllStreams, fileFormat, filePath, fillSegmentsGaps, getFrameCount, invertAllSegments, loadCutSegments, loadMedia, openFilesDialog, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shiftAllSegmentTimes, shuffleSegments, toggleKeyboardShortcuts, toggleLastCommands, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]); + }, [apparentCutSegments, askSetStartTimeOffset, checkFileOpened, clearSegments, closeBatch, closeFileWithConfirm, concatCurrentBatch, createFixedDurationSegments, createNumSegments, createRandomSegments, customOutDir, cutSegments, detectBlackScenes, detectSilentScenes, detectedFps, extractAllStreams, fileFormat, filePath, fillSegmentsGaps, getFrameCount, invertAllSegments, loadCutSegments, loadMedia, openFilesDialog, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shiftAllSegmentTimes, shuffleSegments, toggleKeyboardShortcuts, toggleLastCommands, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]); const showAddStreamSourceDialog = useCallback(async () => { try { diff --git a/src/dialogs.jsx b/src/dialogs.jsx index 7a28e645..9bdb4758 100644 --- a/src/dialogs.jsx +++ b/src/dialogs.jsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { Checkbox, RadioGroup, Paragraph } from 'evergreen-ui'; +import { Button, TextInputField, Checkbox, RadioGroup, Paragraph, LinkIcon } from 'evergreen-ui'; import Swal from 'sweetalert2'; import i18n from 'i18next'; import { Trans } from 'react-i18next'; @@ -13,6 +13,7 @@ import { parseYouTube } from './edlFormats'; import CopyClipboardButton from './components/CopyClipboardButton'; const { dialog, app } = window.require('@electron/remote'); +const electron = window.require('electron'); const ReactSwal = withReactContent(Swal); @@ -403,6 +404,44 @@ export async function showCleanupFilesDialog(cleanupChoicesIn = {}) { return undefined; } +const ParametersInput = ({ description, parameters: parametersIn, onChange: onChangeProp, docUrl }) => { + const [parameters, setParameters] = useState(parametersIn); + + const getParameter = (key) => parameters[key]?.value; + const onChange = (key, value) => setParameters((existing) => { + const newParameters = { ...existing, [key]: { ...existing[key], value } }; + onChangeProp(newParameters); + return newParameters; + }); + + return ( +
{description}
} + + {docUrl && } + + {Object.entries(parametersIn).map(([key, parameter]) => ( +