diff --git a/src/main/index.ts b/src/main/index.ts index 40a99df0..fd3daa9b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,7 @@ import remote from '@electron/remote/main'; import { stat } from 'node:fs/promises'; import assert from 'node:assert'; import timers from 'node:timers/promises'; +import { z } from 'zod'; import logger from './logger.js'; import menu from './menu.js'; @@ -220,12 +221,19 @@ function parseCliArgs(rawArgv = process.argv) { return yargsParser(argsWithoutAppName, { boolean: ['allow-multiple-instances', 'disable-networking'], - string: ['settings-json', 'config-dir'], + string: ['settings-json', 'config-dir', 'lossy-mode'], }); } const argv = parseCliArgs(); +const lossyModeSchema = z.object({ videoEncoder: z.union([z.literal('libx264'), z.literal('libx265'), z.literal('libsvtav1')]) }); +// eslint-disable-next-line prefer-destructuring +const lossyMode = argv['lossyMode'] ? lossyModeSchema.parse(JSON5.parse(argv['lossyMode'])) : undefined; +export { lossyMode }; + +export type LossyMode = z.infer; + if (argv['localesPath'] != null) i18nCommon.setCustomLocalesPath(argv['localesPath']); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1fdd5ca4..34ffccb5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -108,7 +108,7 @@ const { exists } = window.require('fs-extra'); const { lstat } = window.require('fs/promises'); const { parse: parsePath, join: pathJoin, basename, dirname } = window.require('path'); -const { focusWindow, hasDisabledNetworking, quitApp, pathToFileURL, setProgressBar, sendOsNotification } = window.require('@electron/remote').require('./index.js'); +const { focusWindow, hasDisabledNetworking, quitApp, pathToFileURL, setProgressBar, sendOsNotification, lossyMode } = window.require('@electron/remote').require('./index.js'); const hevcPlaybackSupportedPromise = doesPlayerSupportHevcPlayback(); @@ -164,7 +164,7 @@ function App() { const [editingSegmentTagsSegmentIndex, setEditingSegmentTagsSegmentIndex] = useState(); const [editingSegmentTags, setEditingSegmentTags] = useState(); const [mediaSourceQuality, setMediaSourceQuality] = useState(0); - const [smartCutBitrate, setSmartCutBitrate] = useState(); + const [encBitrate, setEncBitrate] = useState(); const [exportCount, setExportCount] = useState(0); const [maxKeyframes, setMaxKeyframes] = useState(1000); @@ -654,10 +654,11 @@ function App() { const areWeCutting = useMemo(() => segmentsToExport.some(({ start, end }) => isCuttingStart(start) || isCuttingEnd(end, fileDuration)), [fileDuration, segmentsToExport]); const needSmartCut = areWeCutting && enableSmartCut; + const isEncoding = needSmartCut || lossyMode != null; const { concatFiles, html5ifyDummy, cutMultiple, concatCutSegments, html5ify, fixInvalidDuration, extractStreams, tryDeleteFiles, - } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, smartCutCustomBitrate: smartCutBitrate, appendFfmpegCommandLog }); + } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate: encBitrate, appendFfmpegCommandLog }); const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ appendFfmpegCommandLog, formatTimecode, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart }); @@ -2759,7 +2760,7 @@ function App() { {/* Dialogs */} - + setStreamsSelectorShown(false)} maxWidth={1000}> {mainStreams && filePath != null && ( diff --git a/src/renderer/src/components/ExportConfirm.tsx b/src/renderer/src/components/ExportConfirm.tsx index 5821c5df..395ef679 100644 --- a/src/renderer/src/components/ExportConfirm.tsx +++ b/src/renderer/src/components/ExportConfirm.tsx @@ -26,6 +26,7 @@ import TextInput from './TextInput'; import { UseSegments } from '../hooks/useSegments'; import ExportDialog from './ExportDialog'; import ToggleExportConfirm from './ToggleExportConfirm'; +import { LossyMode } from '../../../main'; const outDirStyle: CSSProperties = { ...highlightedTextStyle, wordBreak: 'break-all', cursor: 'pointer' }; @@ -92,10 +93,12 @@ function ExportConfirm({ segmentsOrInverse, mainCopiedThumbnailStreams, needSmartCut, - smartCutBitrate, - setSmartCutBitrate, + isEncoding, + encBitrate, + setEncBitrate, toggleSettings, outputPlaybackRate, + lossyMode, } : { areWeCutting: boolean, segmentsToExport: SegmentToExport[], @@ -119,10 +122,12 @@ function ExportConfirm({ segmentsOrInverse: UseSegments['segmentsOrInverse'], mainCopiedThumbnailStreams: FFprobeStream[], needSmartCut: boolean, - smartCutBitrate: number | undefined, - setSmartCutBitrate: Dispatch>, + isEncoding: boolean, + encBitrate: number | undefined, + setEncBitrate: Dispatch>, toggleSettings: () => void, outputPlaybackRate: number, + lossyMode: LossyMode | undefined, }) { const { t } = useTranslation(); @@ -147,8 +152,8 @@ function ExportConfirm({ movFastStart: isMov && isIpod && !movFastStart ? { warning: true, text: t('For the ipod format, it is recommended to activate this option') } : undefined, preserveMovData: isMov && isIpod && preserveMovData ? { warning: true, text: t('For the ipod format, it is recommended to deactivate this option') } : undefined, smartCut: areWeCutting && needSmartCut ? { warning: true, text: t('Smart cut is experimental and will not work on all files.') } : undefined, - cutMode: areWeCutting && !needSmartCut && !keyframeCut ? { text: t('Note: Keyframe cut is recommended for most common files') } : undefined, - avoidNegativeTs: !needSmartCut ? (() => { + cutMode: areWeCutting && !isEncoding && !keyframeCut ? { text: t('Note: Keyframe cut is recommended for most common files') } : undefined, + avoidNegativeTs: !isEncoding ? (() => { if (willMerge) { if (avoidNegativeTs !== 'make_non_negative') { return { text: t('When merging, it\'s generally recommended to set this to "make_non_negative"') }; @@ -181,7 +186,7 @@ function ExportConfirm({ specific, totalNum: generic.filter((n) => n.warning).length + Object.values(specific).filter((n) => n != null && n.warning).length, }; - }, [areWeCutting, areWeCuttingProblematicStreams, avoidNegativeTs, effectiveExportMode, enableOverwriteOutput, isIpod, isMov, keyframeCut, mainCopiedThumbnailStreams, movFastStart, needSmartCut, outFormat, outputPlaybackRate, preserveMovData, t, willMerge]); + }, [areWeCutting, areWeCuttingProblematicStreams, avoidNegativeTs, effectiveExportMode, enableOverwriteOutput, isEncoding, isIpod, isMov, keyframeCut, mainCopiedThumbnailStreams, movFastStart, needSmartCut, outFormat, outputPlaybackRate, preserveMovData, t, willMerge]); const exportModeDescription = useMemo(() => ({ segments_to_chapters: t('Don\'t cut the file, but instead export an unmodified original which has chapters generated from segments'), @@ -266,15 +271,15 @@ function ExportConfirm({ const canEditSegTemplate = !willMerge || !autoDeleteMergedSegments; - const handleSmartCutBitrateToggle = useCallback((checked: boolean) => { - setSmartCutBitrate(() => (checked ? undefined : 10000)); - }, [setSmartCutBitrate]); + const handleEncBitrateToggle = useCallback((checked: boolean) => { + setEncBitrate(() => (checked ? undefined : 10000)); + }, [setEncBitrate]); - const handleSmartCutBitrateChange = useCallback((e: React.ChangeEvent) => { + const handleEncBitrateChange = useCallback((e: React.ChangeEvent) => { const v = parseInt(e.target.value, 10); if (Number.isNaN(v) || v <= 0) return; - setSmartCutBitrate(v); - }, [setSmartCutBitrate]); + setEncBitrate(v); + }, [setEncBitrate]); return ( - {needSmartCut && ( - - - {t('Smart cut auto detect bitrate')} - - -
- {smartCutBitrate != null && ( - <> - - {t('kbit/s')} - - )} - -
- - - - )} - - {!needSmartCut && ( + {!isEncoding && ( {t('Keyframe cut mode')} @@ -578,7 +563,41 @@ function ExportConfirm({ )} - {!needSmartCut && ( + + {isEncoding && ( + + + {t('Smart cut auto detect bitrate')} + + +
+ {encBitrate != null && ( + <> + + {t('kbit/s')} + + )} + +
+ + + + )} + + {lossyMode != null && ( + + + Lossy mode + + + +
{lossyMode.videoEncoder}
+ + + + )} + + {!isEncoding && ( "ffmpeg" avoid_negative_ts diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index 840f17a3..e21e48b3 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -7,11 +7,12 @@ import invariant from 'tiny-invariant'; import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath, unlinkWithRetry, getFrameDuration, isMac } from '../util'; import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine, getDuration, createChaptersFromSegments, readFileMeta, getExperimentalArgs, getVideoTimescaleArgs, logStdoutStderr, runFfmpegConcat, RefuseOverwriteError, runFfmpeg } from '../ffmpeg'; import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams'; -import { getSmartCutParams } from '../smartcut'; +import { needsSmartCut, getCodecParams } from '../smartcut'; import { getGuaranteedSegments, isDurationValid } from '../segments'; import { FFprobeStream } from '../../../../ffprobe'; import { AvoidNegativeTs, Html5ifyMode, PreserveMetadata } from '../../../../types'; import { AllFilesMeta, Chapter, CopyfileStreams, CustomTagsByFile, LiteFFprobeStream, ParamsByStreamId, SegmentToExport } from '../types'; +import { LossyMode } from '../../../main'; const { join, resolve, dirname } = window.require('path'); const { writeFile, mkdir, access, constants: { F_OK, W_OK } } = window.require('fs/promises'); @@ -76,17 +77,18 @@ async function pathExists(path: string) { } } -function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, smartCutCustomBitrate, appendFfmpegCommandLog }: { +function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate, appendFfmpegCommandLog }: { filePath: string | undefined, treatInputFileModifiedTimeAsStart: boolean, treatOutputFileModifiedTimeAsStart: boolean | null | undefined, enableOverwriteOutput: boolean, - needSmartCut: boolean, + isEncoding: boolean, + lossyMode: LossyMode | undefined, outputPlaybackRate: number, cutFromAdjustmentFrames: number, cutToAdjustmentFrames: number, appendLastCommandsLog: (a: string) => void, - smartCutCustomBitrate: number | undefined, + encCustomBitrate: number | undefined, appendFfmpegCommandLog: (args: string[]) => void, }) { const shouldSkipExistingFile = useCallback(async (path: string) => { @@ -524,6 +526,9 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea // then it will cut the part *from* the keyframe to "end", and concat them together and return the concated file // so that for the calling code it looks as if it's just a normal segment async function cutSegment({ start: desiredCutFrom, end: cutTo }: { start: number, end: number }, i: number) { + const onProgress = (progress: number) => onSingleProgress(i, progress / 2); + const onConcatProgress = (progress: number) => onSingleProgress(i, (1 + progress) / 2); + const finalOutPath = join(outputDir, outSegFileNames[i]!); if (await shouldSkipExistingFile(finalOutPath)) return { path: finalOutPath, created: false }; @@ -533,7 +538,8 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea const actualOutputDir = dirname(finalOutPath); if (actualOutputDir !== outputDir) await mkdir(actualOutputDir, { recursive: true }); - if (!needSmartCut) { + if (!isEncoding) { + // simple lossless cut invariant(outFormat != null); await losslessCutSingle({ cutFrom: desiredCutFrom, cutTo, chaptersPath, outPath: finalOutPath, copyFileStreams, keyframeCut, avoidNegativeTs, fileDuration, rotation, allFilesMeta, outFormat, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMovData, preserveChapters, movFastStart, customTagsByFile, paramsByStreamId, onProgress: (progress) => onSingleProgress(i, progress), @@ -541,6 +547,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea return { path: finalOutPath, created: true }; } + // we are probably encoding (smart cut or lossy mode) invariant(filePath != null); // smart cut only supports cutting main file (no externally added files) @@ -551,36 +558,47 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea return match ? [match] : []; }); - const { losslessCutFrom, segmentNeedsSmartCut, videoCodec, videoBitrate: detectedVideoBitrate, videoStreamIndex, videoTimebase } = await getSmartCutParams({ path: filePath, fileDuration, desiredCutFrom, streams: streamsToCopyFromMainFile }); - - if (segmentNeedsSmartCut && !detectedFps) throw new Error('Smart cut is not possible when FPS is unknown'); + const sourceCodecParams = await getCodecParams({ path: filePath, fileDuration, streams: streamsToCopyFromMainFile }); + const { videoStream, videoTimebase } = sourceCodecParams; - console.log('Smart cut on video stream', videoStreamIndex); - - const onProgress = (progress: number) => onSingleProgress(i, progress / 2); - const onConcatProgress = (progress: number) => onSingleProgress(i, (1 + progress) / 2); + const videoCodec = lossyMode ? lossyMode.videoEncoder : sourceCodecParams.videoCodec; const copyFileStreamsFiltered = [{ path: filePath, // with smart cut, we only copy/cut *one* video stream, and *all* other non-video streams (main file only) - streamIds: streamsToCopyFromMainFile.filter((stream) => stream.index === videoStreamIndex || stream.codec_type !== 'video').map((stream) => stream.index), + streamIds: streamsToCopyFromMainFile.filter((stream) => stream.index === videoStream.index || stream.codec_type !== 'video').map((stream) => stream.index), }]; // eslint-disable-next-line no-shadow async function cutEncodeSmartPartWrapper({ cutFrom, cutTo, outPath }: { cutFrom: number, cutTo: number, outPath: string }) { if (await shouldSkipExistingFile(outPath)) return; - if (videoCodec == null || detectedVideoBitrate == null || videoTimebase == null) throw new Error(); + invariant(videoCodec != null); + invariant(sourceCodecParams.videoBitrate != null); + invariant(sourceCodecParams.videoTimebase != null); invariant(filePath != null); invariant(outFormat != null); - await cutEncodeSmartPart({ cutFrom, cutTo, outPath, outFormat, videoCodec, videoBitrate: smartCutCustomBitrate != null ? smartCutCustomBitrate * 1000 : detectedVideoBitrate, videoStreamIndex, videoTimebase, allFilesMeta, copyFileStreams: copyFileStreamsFiltered, ffmpegExperimental }); + await cutEncodeSmartPart({ cutFrom, cutTo, outPath, outFormat, videoCodec, videoBitrate: encCustomBitrate != null ? encCustomBitrate * 1000 : sourceCodecParams.videoBitrate, videoStreamIndex: videoStream.index, videoTimebase: sourceCodecParams.videoTimebase, allFilesMeta, copyFileStreams: copyFileStreamsFiltered, ffmpegExperimental }); } + const cutEncodeWholePart = async () => { + await cutEncodeSmartPartWrapper({ cutFrom: desiredCutFrom, cutTo, outPath: finalOutPath }); + return { path: finalOutPath, created: true }; + }; + + if (lossyMode) { + console.log('Lossy mode: cutting/encoding the whole segment', { desiredCutFrom, cutTo }); + return cutEncodeWholePart(); + } + + const { losslessCutFrom, segmentNeedsSmartCut } = await needsSmartCut({ path: filePath, desiredCutFrom, videoStream }); + if (segmentNeedsSmartCut && !detectedFps) throw new Error('Smart cut is not possible when FPS is unknown'); + console.log('Smart cut on video stream', videoStream.index); + // If we are cutting within two keyframes, just encode the whole part and return that // See https://github.com/mifi/lossless-cut/pull/1267#issuecomment-1236381740 if (segmentNeedsSmartCut && losslessCutFrom > cutTo) { console.log('Segment is between two keyframes, cutting/encoding the whole segment', { desiredCutFrom, losslessCutFrom, cutTo }); - await cutEncodeSmartPartWrapper({ cutFrom: desiredCutFrom, cutTo, outPath: finalOutPath }); - return { path: finalOutPath, created: true }; + return cutEncodeWholePart(); } invariant(outFormat != null); @@ -631,7 +649,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea } finally { if (chaptersPath) await tryDeleteFiles([chaptersPath]); } - }, [needSmartCut, filePath, losslessCutSingle, shouldSkipExistingFile, cutEncodeSmartPart, smartCutCustomBitrate, concatFiles]); + }, [shouldSkipExistingFile, isEncoding, filePath, losslessCutSingle, cutEncodeSmartPart, encCustomBitrate, lossyMode, concatFiles]); const concatCutSegments = useCallback(async ({ customOutDir, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapterNames, preserveMetadataOnMerge, mergedOutFilePath }: { customOutDir: string | undefined, diff --git a/src/renderer/src/smartcut.ts b/src/renderer/src/smartcut.ts index ef1e7ce9..0ec6bd5c 100644 --- a/src/renderer/src/smartcut.ts +++ b/src/renderer/src/smartcut.ts @@ -8,20 +8,11 @@ const { stat } = window.require('fs-extra'); const mapVideoCodec = (codec: string) => ({ av1: 'libsvtav1' }[codec] ?? codec); -// eslint-disable-next-line import/prefer-default-export -export async function getSmartCutParams({ path, fileDuration, desiredCutFrom, streams }: { +export async function needsSmartCut({ path, desiredCutFrom, videoStream }: { path: string, - fileDuration: number | undefined, desiredCutFrom: number, - streams: Pick[], + videoStream: Pick, }) { - const videoStreams = getRealVideoStreams(streams); - if (videoStreams.length > 1) throw new Error('Can only smart cut video with exactly one video stream'); - - const [videoStream] = videoStreams; - - if (videoStream == null) throw new Error('Smart cut only works on videos'); - const readKeyframes = async (window: number) => readKeyframesAroundTime({ filePath: path, streamIndex: videoStream.index, aroundTime: desiredCutFrom, window }); let keyframes = await readKeyframes(10); @@ -32,7 +23,6 @@ export async function getSmartCutParams({ path, fileDuration, desiredCutFrom, st return { losslessCutFrom: keyframeAtExactTime.time, - videoStreamIndex: videoStream.index, segmentNeedsSmartCut: false, }; } @@ -48,12 +38,32 @@ export async function getSmartCutParams({ path, fileDuration, desiredCutFrom, st console.log('Smart cut from keyframe', { keyframe: nextKeyframe.time, desiredCutFrom }); + return { + losslessCutFrom: nextKeyframe.time, + segmentNeedsSmartCut: true, + }; +} + +// eslint-disable-next-line import/prefer-default-export +export async function getCodecParams({ path, fileDuration, streams }: { + path: string, + fileDuration: number | undefined, + streams: Pick[], +}) { + const videoStreams = getRealVideoStreams(streams); + if (videoStreams.length > 1) throw new Error('Can only smart cut video with exactly one video stream'); + + const [videoStream] = videoStreams; + + if (videoStream == null) throw new Error('Smart cut only works on videos'); + let videoBitrate = parseInt(videoStream.bit_rate!, 10); if (Number.isNaN(videoBitrate)) { - console.warn('Unable to detect input bitrate'); + console.warn('Unable to detect input bitrate.'); const stats = await stat(path); if (fileDuration == null) throw new Error('Video duration is unknown, cannot estimate bitrate'); videoBitrate = (stats.size * 8) / fileDuration; + console.warn('Estimated bitrate.', videoBitrate / 1e6, 'Mbit/s'); } // to account for inaccuracies and quality loss @@ -74,9 +84,7 @@ export async function getSmartCutParams({ path, fileDuration, desiredCutFrom, st // const videoProfile = parseProfile(videoStream); return { - losslessCutFrom: nextKeyframe.time, - videoStreamIndex: videoStream.index, - segmentNeedsSmartCut: true, + videoStream, videoCodec, videoBitrate: Math.floor(videoBitrate), videoTimebase: timebase,