From 260314db55d1e6a2f38fb6da39c3a569e5200241 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sat, 14 Mar 2026 12:21:10 +0800 Subject: [PATCH] add FFmpeg `-hwaccel auto` setting and show loading elapsed time closes #2746 --- locales/en/translation.json | 3 ++ src/common/types.ts | 3 ++ src/common/userTypes.ts | 2 +- src/common/util.ts | 5 ++- src/main/configStore.ts | 1 + src/main/ffmpeg.ts | 36 +++++++++---------- src/main/progress.test.ts | 4 +++ src/renderer/src/App.tsx | 8 ++--- src/renderer/src/MediaSourcePlayer.tsx | 12 ++++--- src/renderer/src/components/Settings.tsx | 30 ++++++++++------ src/renderer/src/components/Working.tsx | 30 ++++++++++++---- src/renderer/src/hooks/useFfmpegOperations.ts | 13 ++++--- src/renderer/src/hooks/useSegments.tsx | 16 +++++---- src/renderer/src/hooks/useUserSettingsRoot.ts | 4 +++ 14 files changed, 110 insertions(+), 57 deletions(-) diff --git a/locales/en/translation.json b/locales/en/translation.json index 3e185844..38ea1809 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -223,9 +223,11 @@ "Edit track metadata": "Edit track metadata", "Edit tracks / metadata tags": "Edit tracks / metadata tags", "EDL": "EDL", + "Elapsed: {{seconds}} seconds": "Elapsed: {{seconds}} seconds", "empty": "empty", "Enable \"{{filterName}}\" bitstream filter.": "Enable \"{{filterName}}\" bitstream filter.", "Enable experimental ffmpeg features flag?": "Enable experimental ffmpeg features flag", + "Enable FFmpeg `-hwaccel auto` flag. This can improve performance segment auto detection and FFmpeg-assisted playback speed.": "Enable FFmpeg `-hwaccel auto` flag. This can improve performance segment auto detection and FFmpeg-assisted playback speed.", "Enable HEVC / H265 hardware decoding (you may need to turn this off if you have problems with HEVC files)": "Enable HEVC / H265 hardware decoding (you may need to turn this off if you have problems with HEVC files)", "Enable MOV Faststart?": "Enable MOV Faststart", "Enables shifting when required by the target format.": "Enables shifting when required by the target format.", @@ -525,6 +527,7 @@ "OpenTimelineIO": "OpenTimelineIO", "Options": "Options", "Options affecting exported files": "Options affecting exported files", + "Other": "Other", "Other operations": "Other operations", "Output actions": "Output actions", "Output container format:": "Output container format:", diff --git a/src/common/types.ts b/src/common/types.ts index 1eadca31..9295ba1b 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -7,6 +7,8 @@ export interface KeyBinding { action: KeyboardAction, } +export type FfmpegHwAccel = 'none' | 'auto' | 'vdpau' | 'dxva2' | 'd3d11va' | 'vaapi' | 'qsv' | 'videotoolbox'; + export type CaptureFormat = 'jpeg' | 'png' | 'webp'; export type TimecodeFormat = 'timecodeWithDecimalFraction' | 'frameCount' | 'seconds' | 'timecodeWithFramesFraction'; @@ -100,6 +102,7 @@ export interface Config { thumbnailsEnabled: boolean, keyframesEnabled: boolean, reducedMotion: 'always' | 'never' | 'user', + ffmpegHwaccel: FfmpegHwAccel, } export interface Waveform { diff --git a/src/common/userTypes.ts b/src/common/userTypes.ts index ac5b227e..864d3ca3 100644 --- a/src/common/userTypes.ts +++ b/src/common/userTypes.ts @@ -1,8 +1,8 @@ // ⚠️ These types are a the contract with the user, through the documentation. +// ⚠️ JSDoc comments get converted to user documentation. // ‼️ DO NOT change these types without considering the user impact! // See https://github.com/mifi/lossless-cut/blob/master/expressions.md // https://github.com/mifi/lossless-cut/blob/master/docs.md#custom-exported-file-names -// JSDoc comments get converted to user documentation. /** * Properties of a source file made available to the user in the export file name template. diff --git a/src/common/util.ts b/src/common/util.ts index b8ff86b1..1d1849b6 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -1,4 +1,7 @@ -// eslint-disable-next-line import/prefer-default-export +import type { FfmpegHwAccel } from './types.ts'; + export const parseFfprobeDuration = (durationStr: string | undefined) => ( durationStr != null ? parseFloat(durationStr) : undefined ); + +export const getHwaccelArgs = (hwaccel: FfmpegHwAccel) => (hwaccel !== 'none' ? ['-hwaccel', hwaccel] : []); diff --git a/src/main/configStore.ts b/src/main/configStore.ts index fb91a038..c9f7e6a2 100644 --- a/src/main/configStore.ts +++ b/src/main/configStore.ts @@ -171,6 +171,7 @@ const defaults: Config = { thumbnailsEnabled: false, keyframesEnabled: true, reducedMotion: 'user', + ffmpegHwaccel: 'none', }; const configFileName = 'config.json'; // note: this is also hard-coded inside electron-store diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index e68d21c2..3e2e71c9 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -9,12 +9,12 @@ import type { Readable } from 'node:stream'; import { app, clipboard, nativeImage } from 'electron'; import { platform, arch, isWindows, isLinux } from './util.js'; -import type { CaptureFormat, Waveform } from '../common/types.js'; +import type { CaptureFormat, FfmpegHwAccel, Waveform } from '../common/types.js'; import type { FFprobeFormat } from '../common/ffprobe.js'; import isDev from './isDev.js'; import logger from './logger.js'; import { parseFfmpegProgressLine } from './progress.js'; -import { parseFfprobeDuration } from '../common/util.js'; +import { getHwaccelArgs, parseFfprobeDuration } from '../common/util.js'; import { getFfmpegJpegQuality } from './ffmpegUtil.js'; @@ -283,7 +283,7 @@ interface DetectedSegment { } // https://stackoverflow.com/questions/35675529/using-ffmpeg-how-to-do-a-scene-change-detection-with-timecode -export async function detectSceneChanges({ filePath, streamId, minChange, onProgress, onSegmentDetected, from, to }: { +export async function detectSceneChanges({ filePath, streamId, minChange, onProgress, onSegmentDetected, from, to, ffmpegHwaccel }: { filePath: string, streamId: number | undefined minChange: number | string, @@ -291,9 +291,11 @@ export async function detectSceneChanges({ filePath, streamId, minChange, onProg onSegmentDetected: (p: DetectedSegment) => void, from: number, to: number, + ffmpegHwaccel: FfmpegHwAccel, }) { const args = [ '-hide_banner', + ...getHwaccelArgs(ffmpegHwaccel), ...getInputSeekArgs({ filePath, from, to }), '-map', streamId != null ? `0:${streamId}` : 'v:0', '-filter:v', `select='gt(scene,${minChange})',metadata=print:file=-:direct=1`, // direct=1 to flush stdout immediately @@ -326,7 +328,7 @@ export async function detectSceneChanges({ filePath, streamId, minChange, onProg return { ffmpegArgs: args }; } -async function detectIntervals({ filePath, customArgs, onProgress, onSegmentDetected, from, to, matchLineTokens, boundingMode }: { +async function detectIntervals({ filePath, customArgs, onProgress, onSegmentDetected, from, to, matchLineTokens, boundingMode, ffmpegHwaccel }: { filePath: string, customArgs: string[], onProgress: (p: number) => void, @@ -335,9 +337,11 @@ async function detectIntervals({ filePath, customArgs, onProgress, onSegmentDete to: number, matchLineTokens: (line: string) => DetectedSegment | undefined, boundingMode: boolean, + ffmpegHwaccel: FfmpegHwAccel, }) { const args = [ '-hide_banner', + ...getHwaccelArgs(ffmpegHwaccel), ...getInputSeekArgs({ filePath, from, to }), ...customArgs, '-f', 'null', '-', @@ -377,7 +381,7 @@ async function detectIntervals({ filePath, customArgs, onProgress, onSegmentDete const mapFilterOptions = (options: Record) => Object.entries(options).map(([key, value]) => `${key}=${value}`).join(':'); -export async function blackDetect({ filePath, streamId, filterOptions, boundingMode, onProgress, onSegmentDetected, from, to }: { +export async function blackDetect({ streamId, filterOptions, ...rest }: { filePath: string, streamId: number | undefined, filterOptions: Record, @@ -386,14 +390,10 @@ export async function blackDetect({ filePath, streamId, filterOptions, boundingM onSegmentDetected: (p: DetectedSegment) => void, from: number, to: number, + ffmpegHwaccel: FfmpegHwAccel, }) { return detectIntervals({ - filePath, - onProgress, - onSegmentDetected, - from, - to, - boundingMode, + ...rest, 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\\.]+/); @@ -417,7 +417,7 @@ export async function blackDetect({ filePath, streamId, filterOptions, boundingM }); } -export async function silenceDetect({ filePath, streamId, filterOptions, boundingMode, onProgress, onSegmentDetected, from, to }: { +export async function silenceDetect({ streamId, filterOptions, ...rest }: { filePath: string, streamId: number | undefined, filterOptions: Record, @@ -425,14 +425,10 @@ export async function silenceDetect({ filePath, streamId, filterOptions, boundin onProgress: (p: number) => void, onSegmentDetected: (p: DetectedSegment) => void, from: number, to: number, + ffmpegHwaccel: FfmpegHwAccel, }) { return detectIntervals({ - filePath, - onProgress, - onSegmentDetected, - from, - to, - boundingMode, + ...rest, 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\\.]+)/); @@ -576,7 +572,7 @@ export async function getDuration(filePath: string) { const enableLog = false; const encode = true; -export function createMediaSourceProcess({ path, videoStreamIndex, audioStreamIndexes, seekTo, size, fps, rotate, forceColorspace }: { +export function createMediaSourceProcess({ path, videoStreamIndex, audioStreamIndexes, seekTo, size, fps, rotate, forceColorspace, ffmpegHwaccel }: { path: string, videoStreamIndex?: number | undefined, audioStreamIndexes: number[], @@ -585,6 +581,7 @@ export function createMediaSourceProcess({ path, videoStreamIndex, audioStreamIn fps?: number | undefined, rotate: number | undefined, forceColorspace?: boolean | undefined, + ffmpegHwaccel: FfmpegHwAccel, }) { function getFilters() { const graph: string[] = []; @@ -677,6 +674,7 @@ export function createMediaSourceProcess({ path, videoStreamIndex, audioStreamIn const args = [ '-hide_banner', ...(enableLog ? [] : ['-loglevel', 'error']), + ...getHwaccelArgs(ffmpegHwaccel), // https://stackoverflow.com/questions/16658873/how-to-minimize-the-delay-in-a-live-streaming-with-ffmpeg // https://unix.stackexchange.com/questions/25372/turn-off-buffering-in-pipe diff --git a/src/main/progress.test.ts b/src/main/progress.test.ts index 04c1c7d3..15457b53 100644 --- a/src/main/progress.test.ts +++ b/src/main/progress.test.ts @@ -7,6 +7,10 @@ describe('parseFfmpegProgressLine', () => { const str = 'frame= 2285 fps=135 q=4.0 Lsize=N/A time=00:01:31.36 bitrate=N/A speed=5.38x '; expect(parseFfmpegProgressLine({ line: str, duration: 60 + 31.36 })).toBe(1); }); + test('parse video with elapsed', () => { + const str = 'frame= 4 fps=0.3 q=-0.0 size=N/A time=00:01:02.12 bitrate=N/A speed=4.93x elapsed=0:00:12.59'; + expect(parseFfmpegProgressLine({ line: str, duration: 100 })).toBe(0.6212); + }); test('parse audio 0', () => { const str = 'size= 0kB time=00:00:00.00 bitrate=N/A speed=N/A '; expect(parseFfmpegProgressLine({ line: str, duration: 1 })).toBe(0); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 47a75885..8a358eba 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -187,7 +187,7 @@ function App() { const [selectedBatchFiles, setSelectedBatchFiles] = useState([]); const allUserSettings = useUserSettingsRoot(); - const { captureFormat, keyframeCut, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, autoExportExtraStreams, askBeforeClose, enableImportChapters, enableAskForFileOpenAction, playbackVolume, autoSaveProjectFile, wheelSensitivity, waveformHeight, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, segmentsToChapters, simpleMode, cutFileTemplate, cutMergedFileTemplate, mergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, safeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, darkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, cutToAdjustmentFrames, waveformMode: waveformModePreference, thumbnailsEnabled, keyframesEnabled, reducedMotion } = allUserSettings.settings; + const { captureFormat, keyframeCut, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, autoExportExtraStreams, askBeforeClose, enableImportChapters, enableAskForFileOpenAction, playbackVolume, autoSaveProjectFile, wheelSensitivity, waveformHeight, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, segmentsToChapters, simpleMode, cutFileTemplate, cutMergedFileTemplate, mergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, safeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, darkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, cutToAdjustmentFrames, waveformMode: waveformModePreference, thumbnailsEnabled, keyframesEnabled, reducedMotion, ffmpegHwaccel } = allUserSettings.settings; const { setCaptureFormat, setCustomOutDir, setKeyframeCut, setPlaybackVolume, setExportConfirmEnabled, setSimpleMode, setOutFormatLocked, setSafeOutputFileName, setKeyBindings, resetKeyBindings, setStoreProjectInWorkingDir, setCleanupChoices, toggleDarkMode, setWaveformMode, setThumbnailsEnabled, setKeyframesEnabled, prefersReducedMotion, customOutDir } = allUserSettings; const [showAdvancedSettings, setShowAdvancedSettings] = useState(!simpleMode); @@ -346,7 +346,7 @@ function App() { const { cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, getSegEstimatedSize, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, segmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment, currentCutSegOrWholeTimeline, segColorCounter, - } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta: mainFileMeta?.ffprobeMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog, simpleMode }); + } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta: mainFileMeta?.ffprobeMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog, simpleMode, ffmpegHwaccel }); const { getEdlFilePath, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments }); @@ -586,7 +586,7 @@ function App() { const { concatFiles, html5ifyDummy, cutMultiple, concatCutSegments, html5ify, fixInvalidDuration, extractStreams, tryDeleteFiles, - } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate: encBitrate, appendFfmpegCommandLog }); + } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate: encBitrate, appendFfmpegCommandLog, ffmpegHwaccel }); const { previewFilePath, setPreviewFilePath, usingDummyVideo, setUsingDummyVideo, userHtml5ifyCurrentFile, convertFormatBatch, html5ifyAndLoadWithPreferences } = useHtml5ify({ filePath, hasVideo, hasAudio, workingRef, setWorking, ensureWritableOutDir, customOutDir, batchFiles, enableAutoHtml5ify, setProgress, html5ify, html5ifyDummy, withErrorHandling, showGenericDialog, @@ -2510,7 +2510,7 @@ function App() { {renderSubtitles()} - {filePath != null && compatPlayerEnabled && } + {filePath != null && compatPlayerEnabled && } {bigWaveformEnabled && } diff --git a/src/renderer/src/MediaSourcePlayer.tsx b/src/renderer/src/MediaSourcePlayer.tsx index 3137f047..9f7fb5a2 100644 --- a/src/renderer/src/MediaSourcePlayer.tsx +++ b/src/renderer/src/MediaSourcePlayer.tsx @@ -8,11 +8,12 @@ import isDev from './isDev'; import type { ChromiumHTMLVideoElement } from './types'; import type { FFprobeStream } from '../../common/ffprobe'; import { getFrameDuration } from './util'; +import type { FfmpegHwAccel } from '../../common/types'; const { compatPlayer: { createMediaSourceStream } } = window.require('@electron/remote').require('./index.js'); -async function startPlayback({ path, slaveVideo, masterVideo, videoStreamIndex, audioStreamIndexes, seekTo, signal, size, fps, rotate, onCanPlay, onResetNeeded, onWaiting }: { +async function startPlayback({ path, slaveVideo, masterVideo, videoStreamIndex, audioStreamIndexes, seekTo, signal, size, fps, rotate, onCanPlay, onResetNeeded, onWaiting, ffmpegHwaccel }: { path: string, slaveVideo: ChromiumHTMLVideoElement, masterVideo: ChromiumHTMLVideoElement, @@ -26,6 +27,7 @@ async function startPlayback({ path, slaveVideo, masterVideo, videoStreamIndex, onCanPlay: () => void, onResetNeeded: () => void, onWaiting: () => void, + ffmpegHwaccel: FfmpegHwAccel, }) { let canPlay = false; let bufferEndTime: number | undefined; @@ -88,7 +90,7 @@ async function startPlayback({ path, slaveVideo, masterVideo, videoStreamIndex, throw new Error(`Unsupported MIME type or codec: ${mimeCodec}`); } - mediaSourceProcess = createMediaSourceStream({ path, videoStreamIndex, audioStreamIndexes, seekTo, size, fps, rotate }); + mediaSourceProcess = createMediaSourceStream({ path, videoStreamIndex, audioStreamIndexes, seekTo, size, fps, rotate, ffmpegHwaccel }); console.log('Waiting for media source process to emit first data...'); const readChunk = await mediaSourceProcess.promise; if (readChunk == null) { @@ -305,13 +307,14 @@ async function startPlayback({ path, slaveVideo, masterVideo, videoStreamIndex, processChunk(); } -function MediaSourcePlayer({ rotate, filePath, videoStream, audioStreams, masterVideoRef, mediaSourceQuality }: { +function MediaSourcePlayer({ rotate, filePath, videoStream, audioStreams, masterVideoRef, mediaSourceQuality, ffmpegHwaccel }: { rotate: number | undefined, filePath: string, videoStream: FFprobeStream | undefined, audioStreams: FFprobeStream[], masterVideoRef: RefObject, mediaSourceQuality: number, + ffmpegHwaccel: FfmpegHwAccel, }) { const videoRef = useRef(null); const canvasRef = useRef(null); @@ -382,6 +385,7 @@ function MediaSourcePlayer({ rotate, filePath, videoStream, audioStreams, master onWaiting: () => { setLoading(true); }, + ffmpegHwaccel, }); } catch (err) { console.error('Preview failed', err); @@ -394,7 +398,7 @@ function MediaSourcePlayer({ rotate, filePath, videoStream, audioStreams, master return () => abortController.abort(); // Important that we also have eventId in the deps, so that we can restart the preview when the eventId changes - }, [audioStreamIndexes, filePath, masterVideoRef, mediaSourceQuality, rotate, videoStream]); + }, [audioStreamIndexes, ffmpegHwaccel, filePath, masterVideoRef, mediaSourceQuality, rotate, videoStream]); const onFocus = useCallback>((e) => { // prevent video element from stealing focus in fullscreen mode https://github.com/mifi/lossless-cut/issues/543#issuecomment-1868167775 diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 0e53256b..2c3e6ce5 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -75,7 +75,7 @@ function Settings({ }) { const { t } = useTranslation(); - const { customOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableImportChapters, setEnableImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, hideOsNotifications, setHideOsNotifications, autoLoadTimecode, setAutoLoadTimecode, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, segmentMouseModifierKey, setSegmentMouseModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances, preferStrongColors, setPreferStrongColors, treatInputFileModifiedTimeAsStart, setTreatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, setTreatOutputFileModifiedTimeAsStart, exportConfirmEnabled, toggleExportConfirmEnabled, storeWindowBounds, setStoreWindowBounds, reducedMotion, setReducedMotion } = useUserSettings(); + const { customOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableImportChapters, setEnableImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, hideOsNotifications, setHideOsNotifications, autoLoadTimecode, setAutoLoadTimecode, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, segmentMouseModifierKey, setSegmentMouseModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances, preferStrongColors, setPreferStrongColors, treatInputFileModifiedTimeAsStart, setTreatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, setTreatOutputFileModifiedTimeAsStart, exportConfirmEnabled, toggleExportConfirmEnabled, storeWindowBounds, setStoreWindowBounds, reducedMotion, setReducedMotion, ffmpegHwaccel, setFfmpegHwaccel } = useUserSettings(); const onLangChange = useCallback>((e) => { const { value } = e.target; @@ -460,15 +460,6 @@ function Settings({ )} - {showAdvancedSettings && ( - - {t('Enable HEVC / H265 hardware decoding (you may need to turn this off if you have problems with HEVC files)')} - - - - - )} - {showAdvancedSettings && ( {t('Try to automatically convert to supported format when opening unsupported file?')} @@ -550,6 +541,25 @@ function Settings({ )} + + {showAdvancedSettings && ( + <> +
+ + {t('Enable HEVC / H265 hardware decoding (you may need to turn this off if you have problems with HEVC files)')} + + + + + + + {t('Enable FFmpeg `-hwaccel auto` flag. This can improve performance segment auto detection and FFmpeg-assisted playback speed.')} + + setFfmpegHwaccel(v ? 'auto' : 'none')} /> + + + + )} ); diff --git a/src/renderer/src/components/Working.tsx b/src/renderer/src/components/Working.tsx index 9a71ab37..25537abe 100644 --- a/src/renderer/src/components/Working.tsx +++ b/src/renderer/src/components/Working.tsx @@ -1,7 +1,8 @@ -import { memo } from 'react'; +import { memo, useState } from 'react'; import { motion } from 'motion/react'; import Lottie from 'react-lottie-player/dist/LottiePlayerLight'; -import { Trans } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; +import useInterval from 'react-use/lib/useInterval'; import loadingLottie from '../7077-magic-flow.json'; import Button from './Button'; @@ -13,6 +14,19 @@ function Working({ text, progress, onAbortClick }: { progress?: number | undefined, onAbortClick: () => void }) { + const { t } = useTranslation(); + + const [startedAt] = useState(() => new Date()); + const [elapsedMs, setElapsedMs] = useState(0); + + // Reassure the user that the app is not frozen + // This is because some ffmpeg operations can take a long time without giving any progress updates, which might make the user think that the app is frozen + // https://github.com/mifi/lossless-cut/issues/2746 + + useInterval(() => { + setElapsedMs(Date.now() - startedAt.getTime()); + }, 100); + return (
-
+
-
+
{text}...
+
+ {t('Elapsed: {{seconds}} seconds', { seconds: (elapsedMs / 1000).toFixed(1) })} +
+ {(progress != null) && ( -
+
{`${(progress * 100).toFixed(1)} %`}
)} -
+
diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index f2f4d586..fa1b1fa9 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -11,11 +11,12 @@ import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams'; import { needsSmartCut, getCodecParams } from '../smartcut'; import { getGuaranteedSegments, isDurationValid } from '../segments'; import type { FFprobeStream } from '../../../common/ffprobe'; -import type { AvoidNegativeTs, Html5ifyMode, PreserveMetadata } from '../../../common/types'; +import type { AvoidNegativeTs, FfmpegHwAccel, Html5ifyMode, PreserveMetadata } from '../../../common/types'; import type { AllFilesMeta, Chapter, CopyfileStreams, CustomTagsByFile, LiteFFprobeStream, ParamsByStreamId, SegmentToExport } from '../types'; import type { LossyMode } from '../../../main'; import { UserFacingError } from '../../errors'; import mainApi from '../mainApi'; +import { getHwaccelArgs } from '../../../common/util'; const { join, resolve, dirname } = window.require('path'); const { writeFile, mkdir, access, constants: { W_OK } } = window.require('fs/promises'); @@ -79,7 +80,7 @@ export async function maybeMkDeepOutDir({ outputDir, fileOutPath }: { outputDir: } -function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate, appendFfmpegCommandLog }: { +function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate, appendFfmpegCommandLog, ffmpegHwaccel }: { filePath: string | undefined, treatInputFileModifiedTimeAsStart: boolean, treatOutputFileModifiedTimeAsStart: boolean | null | undefined, @@ -92,6 +93,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea appendLastCommandsLog: (a: string) => void, encCustomBitrate: number | undefined, appendFfmpegCommandLog: (args: string[]) => void, + ffmpegHwaccel: FfmpegHwAccel, }) { const shouldSkipExistingFile = useCallback(async (path: string) => { const fileExists = await mainApi.pathExists(path); @@ -734,14 +736,14 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea const outPath = getHtml5ifiedPath(customOutDir, filePathArg, speed); invariant(outPath != null); - let audio: string | undefined; + let audio: 'hq' | 'lq' | 'copy' | undefined; if (hasAudio) { if (speed === 'slowest') audio = 'hq'; else if (['slow-audio', 'fast-audio'].includes(speed)) audio = 'lq'; else if (['fast-audio-remux'].includes(speed)) audio = 'copy'; } - let video: string | undefined; + let video: 'hq' | 'lq' | 'copy' | undefined; if (hasVideo) { if (speed === 'slowest') video = 'hq'; else if (['slow-audio', 'slow'].includes(speed)) video = 'lq'; @@ -824,6 +826,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea const ffmpegArgs = [ '-hide_banner', + ...((video === 'lq' || video === 'hq') ? getHwaccelArgs(ffmpegHwaccel) : []), '-i', filePathArg, ...videoArgs, @@ -841,7 +844,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea invariant(outPath != null); await transferTimestamps({ inPath: filePathArg, outPath, duration, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart }); return outPath; - }, [appendFfmpegCommandLog, html5ifyDummy, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]); + }, [appendFfmpegCommandLog, ffmpegHwaccel, html5ifyDummy, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]); // https://stackoverflow.com/questions/34118013/how-to-determine-webm-duration-using-ffprobe const fixInvalidDuration = useCallback(async ({ fileFormat, customOutDir, onProgress }: { diff --git a/src/renderer/src/hooks/useSegments.tsx b/src/renderer/src/hooks/useSegments.tsx index 43714737..4371ef80 100644 --- a/src/renderer/src/hooks/useSegments.tsx +++ b/src/renderer/src/hooks/useSegments.tsx @@ -31,6 +31,7 @@ import * as Dialog from '../components/Dialog'; import { UserFacingError } from '../../errors'; import { editSegmentByExpressionHelpUrl, selectSegmentByExpressionHelpUrl } from '../../../common/constants'; import type { Segment as ScopeSegment } from '../../../common/userTypes'; +import type { FfmpegHwAccel } from '../../../common/types'; const remote = window.require('@electron/remote'); const { shell } = remote; @@ -41,7 +42,7 @@ type ParameterDialogParameters = Record; const offsetSegments = (segments: DefiniteSegmentBase[], offset: number) => segments.map((s) => ({ start: s.start + offset, end: s.end + offset })); -function useSegments({ filePath, workingRef, setWorking, setProgress, videoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog, simpleMode }: { +function useSegments({ filePath, workingRef, setWorking, setProgress, videoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog, simpleMode, ffmpegHwaccel }: { filePath?: string | undefined, workingRef: MutableRefObject, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, @@ -64,6 +65,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea handleError: HandleError, showGenericDialog: ShowGenericDialog, simpleMode: boolean, + ffmpegHwaccel: FfmpegHwAccel, }) { const { t } = useTranslation(); @@ -318,8 +320,8 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea setFfmpegParametersForDialog(dialogType, 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 (onSegmentDetected) => blackDetect({ filePath, streamId: activeVideoStreamIndex, filterOptions, boundingMode: mode === '1', onProgress: setProgress, onSegmentDetected, from: start, to: end }) }); - }, [currentCutSegOrWholeTimeline, deleteCurrentCutSeg, showParametersDialog, getFfmpegParameters, setFfmpegParametersForDialog, filePath, detectSegments, activeVideoStreamIndex, setProgress]); + await detectSegments({ name: 'blackScenes', workingText: i18n.t('Detecting black scenes'), errorText: i18n.t('Failed to detect black scenes'), fn: async (onSegmentDetected) => blackDetect({ filePath, streamId: activeVideoStreamIndex, filterOptions, boundingMode: mode === '1', onProgress: setProgress, onSegmentDetected, from: start, to: end, ffmpegHwaccel }) }); + }, [currentCutSegOrWholeTimeline, deleteCurrentCutSeg, showParametersDialog, getFfmpegParameters, setFfmpegParametersForDialog, filePath, detectSegments, activeVideoStreamIndex, setProgress, ffmpegHwaccel]); const detectSilentScenes = useCallback(async () => { const { start, end } = currentCutSegOrWholeTimeline; @@ -331,8 +333,8 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea 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 (onSegmentDetected) => silenceDetect({ filePath, streamId: [...activeAudioStreamIndexes][0], filterOptions, boundingMode: mode === '1', onProgress: setProgress, onSegmentDetected, from: start, to: end }) }); - }, [activeAudioStreamIndexes, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress, showParametersDialog]); + await detectSegments({ name: 'silentScenes', workingText: i18n.t('Detecting silent scenes'), errorText: i18n.t('Failed to detect silent scenes'), fn: async (onSegmentDetected) => silenceDetect({ filePath, streamId: [...activeAudioStreamIndexes][0], filterOptions, boundingMode: mode === '1', onProgress: setProgress, onSegmentDetected, from: start, to: end, ffmpegHwaccel }) }); + }, [activeAudioStreamIndexes, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, ffmpegHwaccel, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress, showParametersDialog]); const detectSceneChanges = useCallback(async () => { const { start, end } = currentCutSegOrWholeTimeline; @@ -345,8 +347,8 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea // eslint-disable-next-line prefer-destructuring const minChange = parameters['minChange']; invariant(minChange != null); - await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async (onSegmentDetected) => ffmpegDetectSceneChanges({ filePath, streamId: activeVideoStreamIndex, minChange, onProgress: setProgress, onSegmentDetected, from: start, to: end }) }); - }, [activeVideoStreamIndex, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress, showParametersDialog]); + await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async (onSegmentDetected) => ffmpegDetectSceneChanges({ filePath, streamId: activeVideoStreamIndex, minChange, onProgress: setProgress, onSegmentDetected, from: start, to: end, ffmpegHwaccel }) }); + }, [activeVideoStreamIndex, currentCutSegOrWholeTimeline, deleteCurrentCutSeg, detectSegments, ffmpegHwaccel, filePath, getFfmpegParameters, setFfmpegParametersForDialog, setProgress, showParametersDialog]); const createSegmentsFromKeyframes = useCallback(async () => { const { start, end } = currentCutSegOrWholeTimeline; diff --git a/src/renderer/src/hooks/useUserSettingsRoot.ts b/src/renderer/src/hooks/useUserSettingsRoot.ts index c30fd375..3be66151 100644 --- a/src/renderer/src/hooks/useUserSettingsRoot.ts +++ b/src/renderer/src/hooks/useUserSettingsRoot.ts @@ -194,6 +194,8 @@ export default function useUserSettingsRoot() { useEffect(() => safeSetConfig({ keyframesEnabled }), [keyframesEnabled]); const [reducedMotion, setReducedMotion] = useState(safeGetConfigInitial('reducedMotion')); useEffect(() => safeSetConfig({ reducedMotion }), [reducedMotion]); + const [ffmpegHwaccel, setFfmpegHwaccel] = useState(safeGetConfigInitial('ffmpegHwaccel')); + useEffect(() => safeSetConfig({ ffmpegHwaccel }), [ffmpegHwaccel]); const resetKeyBindings = useCallback(() => { @@ -312,6 +314,7 @@ export default function useUserSettingsRoot() { thumbnailsEnabled, keyframesEnabled, reducedMotion, + ffmpegHwaccel, }; return { @@ -394,6 +397,7 @@ export default function useUserSettingsRoot() { setKeyframesEnabled, prefersReducedMotion, setReducedMotion, + setFfmpegHwaccel, }; }