diff --git a/src/common/util.ts b/src/common/util.ts new file mode 100644 index 00000000..b8ff86b1 --- /dev/null +++ b/src/common/util.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export +export const parseFfprobeDuration = (durationStr: string | undefined) => ( + durationStr != null ? parseFloat(durationStr) : undefined +); diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts index 10ac685f..8723dbeb 100644 --- a/src/main/ffmpeg.ts +++ b/src/main/ffmpeg.ts @@ -10,9 +10,11 @@ import { app, clipboard, nativeImage } from 'electron'; import { platform, arch, isWindows, isLinux } from './util.js'; import type { CaptureFormat, 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'; // cannot use process.kill: https://github.com/sindresorhus/execa/issues/1177 const runningFfmpegs = new Set<{ @@ -552,7 +554,7 @@ export async function captureFrameToFile({ timestamp, videoPath, outPath, qualit } -async function readFormatData(filePath: string) { +async function readFormatData(filePath: string): Promise { logger.info('readFormatData', filePath); const { stdout } = await runFfprobe([ @@ -562,7 +564,7 @@ async function readFormatData(filePath: string) { } export async function getDuration(filePath: string) { - return parseFloat((await readFormatData(filePath)).duration); + return parseFfprobeDuration((await readFormatData(filePath)).duration); } const enableLog = false; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3b3ddfde..1e59ce7d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -58,7 +58,7 @@ import { readFileFfprobeMeta, getDefaultOutFormat, setCustomFfPath as ffmpegSetCustomFfPath, isIphoneHevc, isProblematicAvc1, tryMapChaptersToEdl, - getDuration, getTimecodeFromStreams, createChaptersFromSegments, + getTimecodeFromStreams, createChaptersFromSegments, RefuseOverwriteError, extractSubtitleTrackToSegments, mapRecommendedDefaultFormat, getFfCommandLine, @@ -95,6 +95,8 @@ import type { BatchFile, Chapter, CustomTagsByFile, EdlExportType, EdlFileType, import { goToTimecodeDirectArgsSchema, openFilesActionArgsSchema } from './types'; import type { CaptureFormat, KeyboardAction, ApiActionRequest } from '../../common/types.js'; import type { FFprobeChapter, FFprobeStream } from '../../common/ffprobe.js'; +import { parseFfprobeDuration } from '../../common/util.js'; + import useLoading from './hooks/useLoading'; import useVideo from './hooks/useVideo'; import useTimecode from './hooks/useTimecode'; @@ -670,7 +672,7 @@ function App() { }, [videoRef, setCommandedTime, setPlaybackRate, setPreviewFilePath, setUsingDummyVideo, setPlaying, playingRef, setPlaybackMode, cutSegmentsHistory, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setSubtitlesByStreamId, setOutputPlaybackRateState]); - const showUnsupportedFileMessage = useCallback(() => { + const showNotNativelySupportedMessage = useCallback(() => { showNotification({ timer: 13000, text: i18n.t('File is not natively supported. Preview playback may be slow and of low quality, but the final export will be lossless. You may convert the file from the menu for a better preview.') }); }, [showNotification]); @@ -1479,7 +1481,7 @@ function App() { if (existingHtml5FriendlyFile && !existingHtml5FriendlyFile.usingDummyVideo) { showPreviewFileLoadedMessage(basename(existingHtml5FriendlyFile.path)); } else if (needsAutoHtml5ify) { - showUnsupportedFileMessage(); + showNotNativelySupportedMessage(); } else if (isAudioDefinitelyNotSupported(ffprobeMeta.streams)) { showNotification({ icon: 'info', text: i18n.t('The audio track is not supported while previewing. You can convert to a supported format from the menu') }); } else if (!validDuration) { @@ -1495,7 +1497,7 @@ function App() { resetState(); throw err; } - }, [storeProjectInWorkingDir, setWorking, loadEdlFile, getEdlFilePath, enableAskForImportChapters, ensureAccessToSourceDir, loadCutSegments, autoLoadTimecode, enableNativeHevc, ensureWritableOutDir, customOutDir, resetState, clearSegColorCounter, setCopyStreamIdsForPath, setDetectedFileFormat, outFormatLocked, setUsingDummyVideo, setPreviewFilePath, html5ifyAndLoadWithPreferences, setFileFormat, showNotification, showPreviewFileLoadedMessage, showUnsupportedFileMessage]); + }, [storeProjectInWorkingDir, setWorking, loadEdlFile, getEdlFilePath, enableAskForImportChapters, ensureAccessToSourceDir, loadCutSegments, autoLoadTimecode, enableNativeHevc, ensureWritableOutDir, customOutDir, resetState, clearSegColorCounter, setCopyStreamIdsForPath, setDetectedFileFormat, outFormatLocked, setUsingDummyVideo, setPreviewFilePath, html5ifyAndLoadWithPreferences, setFileFormat, showNotification, showPreviewFileLoadedMessage, showNotNativelySupportedMessage]); const toggleLastCommands = useCallback(() => setLastCommandsVisible((val) => !val), []); const toggleSettings = useCallback(() => setSettingsVisible((val) => !val), []); @@ -2177,7 +2179,7 @@ function App() { ) || error.code === PIPELINE_ERROR_DECODE ) - && !usingPreviewFile + && !usingPreviewFile // if we are already using preview file, we shouldn't try to do it again && filePath && !(error.code === MEDIA_ERR_SRC_NOT_SUPPORTED && error.message?.startsWith('DEMUXER_ERROR_COULD_NOT_PARSE')) ) { @@ -2185,15 +2187,21 @@ function App() { try { setWorking({ text: i18n.t('Converting to supported format') }); - console.log('Trying to create preview'); + console.log('Trying to convert to supported format'); - if (!isDurationValid(await getDuration(filePath))) throw new UserFacingError(i18n.t('Invalid duration')); + // A valid duration is needed to create a html5ified dummy (`fastest`). + if (!isDurationValid(parseFfprobeDuration(mainFileFormat?.duration))) { + throw new UserFacingError(i18n.t('Invalid duration')); + } if (hasVideo || hasAudio) { await html5ifyAndLoadWithPreferences(customOutDir, filePath, 'fastest', hasVideo, hasAudio); - showUnsupportedFileMessage(); + showNotNativelySupportedMessage(); } } catch (err) { + if (err instanceof UserFacingError) { + throw err; + } console.error(err); showPlaybackFailedMessage(); } finally { @@ -2205,7 +2213,7 @@ function App() { } catch (err) { toastError(err); } - }, [videoRef, fileUri, usingPreviewFile, filePath, workingRef, setWorking, hasVideo, hasAudio, html5ifyAndLoadWithPreferences, customOutDir, showUnsupportedFileMessage]); + }, [videoRef, fileUri, usingPreviewFile, filePath, workingRef, setWorking, mainFileFormat?.duration, hasVideo, hasAudio, html5ifyAndLoadWithPreferences, customOutDir, showNotNativelySupportedMessage]); const onVideoFocus = 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/ffmpeg.ts b/src/renderer/src/ffmpeg.ts index 7282cf4a..0d3cd49b 100644 --- a/src/renderer/src/ffmpeg.ts +++ b/src/renderer/src/ffmpeg.ts @@ -219,7 +219,7 @@ export function tryMapChaptersToEdl(chapters: FFprobeChapter[]) { export async function createChaptersFromSegments({ segmentPaths, chapterNames }: { segmentPaths: string[], chapterNames?: (string | undefined)[] | undefined }) { if (!chapterNames) return undefined; try { - const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 }); + const durations = await pMap(segmentPaths, async (segmentPath) => (await getDuration(segmentPath)) ?? 0, { concurrency: 3 }); let timeAt = 0; return durations.map((duration, i) => { const ret = { start: timeAt, end: timeAt + duration, name: chapterNames[i] }; diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index afe511c1..996fbfb5 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -5,7 +5,7 @@ import pMap from 'p-map'; import invariant from 'tiny-invariant'; import i18n from 'i18next'; -import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath, unlinkWithRetry, getFrameDuration, isMac } from '../util'; +import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath, unlinkWithRetry, getFrameDuration, isMac, html5ifiedPrefix, html5dummySuffix } from '../util'; import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine, getDuration, createChaptersFromSegments, readFileFfprobeMeta, getExperimentalArgs, getVideoTimescaleArgs, logStdoutStderr, runFfmpegConcat, RefuseOverwriteError, runFfmpeg } from '../ffmpeg'; import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams'; import { needsSmartCut, getCodecParams } from '../smartcut'; @@ -132,7 +132,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea console.log('Merging files', { paths }, 'to', outPath); - const durations = await pMap(paths, getDuration, { concurrency: 1 }); + const durations = await pMap(paths, async (path) => (await getDuration(path)) ?? 0, { concurrency: 1 }); const totalDuration = sum(durations); let chaptersPath: string | undefined; @@ -682,9 +682,50 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea await concatFiles({ paths: segmentPaths, outDir, outPath: mergedOutFilePath, metadataFromPath, outFormat, includeAllStreams: true, streams, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge }); }, [concatFiles, filePath, shouldSkipExistingFile]); + // This is just used to load something into the player with correct duration, + // so that the user can seek and then we render frames using ffmpeg & MediaSource + const html5ifyDummy = useCallback(async ({ filePath: filePathArg, outPath, onProgress }: { + filePath: string, + outPath: string, + onProgress: (p: number) => void, + }) => { + console.log('Making ffmpeg-assisted dummy file', { filePathArg, outPath }); + + const duration = await getDuration(filePathArg); + + const ffmpegArgs = [ + '-hide_banner', + + // This is just a fast way of generating an empty dummy file + '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', + '-t', String(duration), + '-acodec', 'flac', + '-y', outPath, + ]; + + appendFfmpegCommandLog(ffmpegArgs); + const result = await runFfmpegWithProgress({ ffmpegArgs, duration, onProgress }); + logStdoutStderr(result); + + await transferTimestamps({ inPath: filePathArg, outPath, duration, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart }); + }, [appendFfmpegCommandLog, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]); + const html5ify = useCallback(async ({ customOutDir, filePath: filePathArg, speed, hasAudio, hasVideo, onProgress }: { - customOutDir: string | undefined, filePath: string, speed: Html5ifyMode, hasAudio: boolean, hasVideo: boolean, onProgress: (p: number) => void, + customOutDir: string | undefined, + filePath: string, + speed: Html5ifyMode, + hasAudio: boolean, + hasVideo: boolean, + onProgress: (p: number) => void, }) => { + console.log('html5ifyAndLoad', { speed, hasVideo, hasAudio }); + + if (speed === 'fastest') { + const path = getSuffixedOutPath({ customOutDir, filePath: filePathArg, nameSuffix: `${html5ifiedPrefix}${html5dummySuffix}.mkv` }); + await html5ifyDummy({ filePath: filePathArg, outPath: path, onProgress }); + return path; + } + const outPath = getHtml5ifiedPath(customOutDir, filePathArg, speed); invariant(outPath != null); @@ -795,35 +836,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea invariant(outPath != null); await transferTimestamps({ inPath: filePathArg, outPath, duration, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart }); return outPath; - }, [appendFfmpegCommandLog, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]); - - // This is just used to load something into the player with correct duration, - // so that the user can seek and then we render frames using ffmpeg & MediaSource - const html5ifyDummy = useCallback(async ({ filePath: filePathArg, outPath, onProgress }: { - filePath: string, - outPath: string, - onProgress: (p: number) => void, - }) => { - console.log('Making ffmpeg-assisted dummy file', { filePathArg, outPath }); - - const duration = await getDuration(filePathArg); - - const ffmpegArgs = [ - '-hide_banner', - - // This is just a fast way of generating an empty dummy file - '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', - '-t', String(duration), - '-acodec', 'flac', - '-y', outPath, - ]; - - appendFfmpegCommandLog(ffmpegArgs); - const result = await runFfmpegWithProgress({ ffmpegArgs, duration, onProgress }); - logStdoutStderr(result); - - await transferTimestamps({ inPath: filePathArg, outPath, duration, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart }); - }, [appendFfmpegCommandLog, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]); + }, [appendFfmpegCommandLog, 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/useHtml5ify.tsx b/src/renderer/src/hooks/useHtml5ify.tsx index cb6e19f8..f2a2c317 100644 --- a/src/renderer/src/hooks/useHtml5ify.tsx +++ b/src/renderer/src/hooks/useHtml5ify.tsx @@ -7,7 +7,6 @@ import type { Html5ifyMode } from '../../../common/types'; import { DirectoryAccessDeclinedError } from '../../errors'; import getSwal from '../swal'; import Checkbox from '../components/Checkbox'; -import { getSuffixedOutPath, html5dummySuffix, html5ifiedPrefix } from '../util'; import type { SetWorking } from './useLoading'; import type { WithErrorHandling } from './useErrorHandling'; import type { FfmpegOperations } from './useFfmpegOperations'; @@ -18,7 +17,7 @@ import { ButtonRow } from '../components/Dialog'; import { DialogButton } from '../components/Button'; -export default function useHtml5ify({ filePath, hasVideo, hasAudio, workingRef, setWorking, ensureWritableOutDir, customOutDir, batchFiles, enableAutoHtml5ify, setProgress, html5ify, html5ifyDummy, withErrorHandling, showGenericDialog }: { +export default function useHtml5ify({ filePath, hasVideo, hasAudio, workingRef, setWorking, ensureWritableOutDir, customOutDir, batchFiles, enableAutoHtml5ify, setProgress, html5ify, withErrorHandling, showGenericDialog }: { filePath: string | undefined, hasVideo: boolean, hasAudio: boolean, @@ -39,36 +38,17 @@ export default function useHtml5ify({ filePath, hasVideo, hasAudio, workingRef, const [rememberConvertToSupportedFormat, setRememberConvertToSupportedFormat] = useState(); const html5ifyAndLoad = useCallback(async (cod: string | undefined, fp: string, speed: Html5ifyMode, hv: boolean, ha: boolean) => { - const usesDummyVideo = speed === 'fastest'; - console.log('html5ifyAndLoad', { speed, hasVideo: hv, hasAudio: ha, usesDummyVideo }); - - async function doHtml5ify() { - if (speed == null) return undefined; - if (speed === 'fastest') { - const path = getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${html5dummySuffix}.mkv` }); - try { - setProgress(0); - await html5ifyDummy({ filePath: fp, outPath: path, onProgress: setProgress }); - } finally { - setProgress(undefined); - } - return path; - } + try { + setProgress(0); + const path = await html5ify({ customOutDir: cod, filePath: fp, speed, hasAudio: ha, hasVideo: hv, onProgress: setProgress }); + if (!path) return; - try { - const shouldIncludeVideo = !usesDummyVideo && hv; - return await html5ify({ customOutDir: cod, filePath: fp, speed, hasAudio: ha, hasVideo: shouldIncludeVideo, onProgress: setProgress }); - } finally { - setProgress(undefined); - } + setPreviewFilePath(path); + setUsingDummyVideo(speed === 'fastest'); + } finally { + setProgress(undefined); } - - const path = await doHtml5ify(); - if (!path) return; - - setPreviewFilePath(path); - setUsingDummyVideo(usesDummyVideo); - }, [html5ify, html5ifyDummy, setProgress]); + }, [html5ify, setProgress]); const askForHtml5ifySpeed = useCallback(async ({ allowedOptions, showRemember, initialOption }: { allowedOptions: Html5ifyMode[], @@ -211,13 +191,10 @@ export default function useHtml5ify({ filePath, hasVideo, hasAudio, workingRef, try { await withErrorHandling(async () => { - // eslint-disable-next-line no-restricted-syntax for (const path of filePaths) { try { - // eslint-disable-next-line no-await-in-loop const newCustomOutDir = await ensureWritableOutDir({ inputPath: path, outDir: customOutDir }); - // eslint-disable-next-line no-await-in-loop await html5ify({ customOutDir: newCustomOutDir, filePath: path, speed, hasAudio: true, hasVideo: true, onProgress: setTotalProgress }); } catch (err2) { if (err2 instanceof DirectoryAccessDeclinedError) return; diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 825c77ff..05cc2405 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -256,7 +256,7 @@ export async function findExistingHtml5FriendlyFile(fp: string, cod: string | un }; } -export function getHtml5ifiedPath(cod: string | undefined, fp: string, type: Html5ifyMode) { +export function getHtml5ifiedPath(cod: string | undefined, fp: string, type: Exclude) { // See also inside ffmpegHtml5ify const ext = (isMac && ['slowest', 'slow', 'slow-audio'].includes(type)) ? 'mp4' : 'mkv'; return getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${type}.${ext}` });