diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 80f019c9..4051ed86 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -54,7 +54,7 @@ import { getSegColor } from './util/colors'; import { getStreamFps, isCuttingStart, isCuttingEnd, readFileMeta, getDefaultOutFormat, - extractStreams, setCustomFfPath as ffmpegSetCustomFfPath, + setCustomFfPath as ffmpegSetCustomFfPath, isIphoneHevc, isProblematicAvc1, tryMapChaptersToEdl, getDuration, getTimecodeFromStreams, createChaptersFromSegments, RefuseOverwriteError, extractSubtitleTrackToSegments, @@ -594,7 +594,7 @@ function App() { const needSmartCut = !!(areWeCutting && enableSmartCut); const { - appendFfmpegCommandLog, concatFiles, html5ifyDummy, cutMultiple, autoConcatCutSegments, html5ify, fixInvalidDuration, + appendFfmpegCommandLog, concatFiles, html5ifyDummy, cutMultiple, autoConcatCutSegments, html5ify, fixInvalidDuration, extractStreams, } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, appendLastCommandsLog, smartCutCustomBitrate: smartCutBitrate }); const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ appendFfmpegCommandLog, formatTimecode, treatOutputFileModifiedTimeAsStart }); @@ -1083,7 +1083,7 @@ function App() { try { setProgress(undefined); // If extracting extra streams takes a long time, prevent loader from being stuck at 100% setWorking({ text: i18n.t('Extracting {{count}} unprocessable tracks', { count: nonCopiedExtraStreams.length }) }); - await extractStreams({ filePath, customOutDir, streams: nonCopiedExtraStreams, enableOverwriteOutput }); + await extractStreams({ customOutDir, streams: nonCopiedExtraStreams }); notices.push(i18n.t('Unprocessable streams were exported as separate files.')); } catch (err) { console.error('Extra stream export failed', err); @@ -1130,7 +1130,7 @@ function App() { setWorking(undefined); setProgress(undefined); } - }, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, selectedSegmentsOrInverse, t, mergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateMergedFileNames, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, showOsNotification, handleExportFailed]); + }, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, selectedSegmentsOrInverse, t, mergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateMergedFileNames, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, extractStreams, showOsNotification, handleExportFailed]); const onExportPress = useCallback(async () => { if (!filePath) return; @@ -1531,7 +1531,7 @@ function App() { try { setWorking({ text: i18n.t('Extracting all streams') }); setStreamsSelectorShown(false); - const [firstExtractedPath] = await extractStreams({ customOutDir, filePath, streams: mainCopiedStreams, enableOverwriteOutput }); + const [firstExtractedPath] = await extractStreams({ customOutDir, streams: mainCopiedStreams }); if (!hideAllNotifications && firstExtractedPath != null) { showOsNotification(i18n.t('All tracks have been extracted')); openDirToast({ icon: 'success', filePath: firstExtractedPath, text: i18n.t('All streams have been extracted as separate files') }); @@ -1548,7 +1548,7 @@ function App() { } finally { setWorking(undefined); } - }, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainCopiedStreams, setWorking, showOsNotification, workingRef]); + }, [customOutDir, extractStreams, filePath, hideAllNotifications, mainCopiedStreams, setWorking, showOsNotification, workingRef]); const userHtml5ifyCurrentFile = useCallback(async ({ ignoreRememberedValue }: { ignoreRememberedValue?: boolean } = {}) => { if (!filePath) return; @@ -2101,7 +2101,7 @@ function App() { try { setWorking({ text: i18n.t('Extracting track') }); // setStreamsSelectorShown(false); - const [firstExtractedPath] = await extractStreams({ customOutDir, filePath, streams: mainStreams.filter((s) => s.index === index), enableOverwriteOutput }); + const [firstExtractedPath] = await extractStreams({ customOutDir, streams: mainStreams.filter((s) => s.index === index) }); if (!hideAllNotifications && firstExtractedPath != null) { showOsNotification(i18n.t('Track has been extracted')); openDirToast({ icon: 'success', filePath: firstExtractedPath, text: i18n.t('Track has been extracted') }); @@ -2118,7 +2118,7 @@ function App() { } finally { setWorking(undefined); } - }, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainStreams, setWorking, showOsNotification, workingRef]); + }, [customOutDir, extractStreams, filePath, hideAllNotifications, mainStreams, setWorking, showOsNotification, workingRef]); const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]); diff --git a/src/renderer/src/ffmpeg.ts b/src/renderer/src/ffmpeg.ts index 0f6bec8f..edf7fefc 100644 --- a/src/renderer/src/ffmpeg.ts +++ b/src/renderer/src/ffmpeg.ts @@ -6,21 +6,19 @@ import minBy from 'lodash/minBy'; import invariant from 'tiny-invariant'; import { pcmAudioCodecs, getMapStreamsArgs, isMov } from './util/streams'; -import { getSuffixedOutPath, isExecaError } from './util'; +import { isExecaError } from './util'; import { isDurationValid } from './segments'; import { FFprobeChapter, FFprobeFormat, FFprobeProbeResult, FFprobeStream } from '../../../ffprobe'; import { parseSrt, parseSrtToSegments } from './edlFormats'; -import { CopyfileStreams, LiteFFprobeStream } from './types'; +import { CopyfileStreams } from './types'; import { UnsupportedFileError } from '../errors'; -const { pathExists } = window.require('fs-extra'); - const { ffmpeg, fileTypePromise } = window.require('@electron/remote').require('./index.js'); const { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfmpeg, runFfprobe, getFfmpegPath, setCustomFfPath } = ffmpeg; -export { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfprobe, getFfmpegPath, setCustomFfPath }; +export { renderWaveformPng, mapTimesToSegments, detectSceneChanges, captureFrames, captureFrame, getFfCommandLine, runFfmpegConcat, runFfmpegWithProgress, html5ify, getDuration, abortFfmpegs, runFfmpeg, runFfprobe, getFfmpegPath, setCustomFfPath }; export class RefuseOverwriteError extends Error { @@ -375,137 +373,6 @@ export async function readFileMeta(filePath: string) { } } -function getPreferredCodecFormat(stream: LiteFFprobeStream) { - const map = { - mp3: { format: 'mp3', ext: 'mp3' }, - opus: { format: 'opus', ext: 'opus' }, - vorbis: { format: 'ogg', ext: 'ogg' }, - h264: { format: 'mp4', ext: 'mp4' }, - hevc: { format: 'mp4', ext: 'mp4' }, - eac3: { format: 'eac3', ext: 'eac3' }, - - subrip: { format: 'srt', ext: 'srt' }, - mov_text: { format: 'mp4', ext: 'mp4' }, - - m4a: { format: 'ipod', ext: 'm4a' }, - aac: { format: 'adts', ext: 'aac' }, - jpeg: { format: 'image2', ext: 'jpeg' }, - png: { format: 'image2', ext: 'png' }, - - // TODO add more - // TODO allow user to change? - }; - - const match = map[stream.codec_name]; - if (match) return match; - - // default fallbacks: - if (stream.codec_type === 'video') return { ext: 'mkv', format: 'matroska' }; - if (stream.codec_type === 'audio') return { ext: 'mka', format: 'matroska' }; - if (stream.codec_type === 'subtitle') return { ext: 'mks', format: 'matroska' }; - if (stream.codec_type === 'data') return { ext: 'bin', format: 'data' }; // https://superuser.com/questions/1243257/save-data-stream - - return undefined; -} - -async function extractNonAttachmentStreams({ customOutDir, filePath, streams, enableOverwriteOutput }: { - customOutDir?: string | undefined, filePath: string, streams: FFprobeStream[], enableOverwriteOutput: boolean | undefined, -}) { - if (streams.length === 0) return []; - - const outStreams = streams.map((s) => ({ - index: s.index, - codec: s.codec_name || s.codec_tag_string || s.codec_type, - type: s.codec_type, - format: getPreferredCodecFormat(s), - })) - .filter(({ format, index }) => format != null && index != null); - - // console.log(outStreams); - - - let streamArgs: string[] = []; - const outPaths = await pMap(outStreams, async ({ index, codec, type, format: { format, ext } }) => { - const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); - if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError(); - - streamArgs = [ - ...streamArgs, - '-map', `0:${index}`, '-c', 'copy', '-f', format, '-y', outPath, - ]; - return outPath; - }, { concurrency: 1 }); - - const ffmpegArgs = [ - '-hide_banner', - - '-i', filePath, - ...streamArgs, - ]; - - const { stdout } = await runFfmpeg(ffmpegArgs); - console.log(stdout.toString('utf8')); - - return outPaths; -} - -async function extractAttachmentStreams({ customOutDir, filePath, streams, enableOverwriteOutput }: { - customOutDir?: string | undefined, filePath: string, streams: FFprobeStream[], enableOverwriteOutput: boolean | undefined, -}) { - if (streams.length === 0) return []; - - console.log('Extracting', streams.length, 'attachment streams'); - - let streamArgs: string[] = []; - const outPaths = await pMap(streams, async ({ index, codec_name: codec, codec_type: type }) => { - const ext = codec || 'bin'; - const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); - if (outPath == null) throw new Error(); - if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError(); - - streamArgs = [ - ...streamArgs, - `-dump_attachment:${index}`, outPath, - ]; - return outPath; - }, { concurrency: 1 }); - - const ffmpegArgs = [ - '-y', - '-hide_banner', - '-loglevel', 'error', - ...streamArgs, - '-i', filePath, - ]; - - try { - const { stdout } = await runFfmpeg(ffmpegArgs); - console.log(stdout.toString('utf8')); - } catch (err) { - // Unfortunately ffmpeg will exit with code 1 even though it's a success - // Note: This is kind of hacky: - if (err instanceof Error && 'exitCode' in err && 'stderr' in err && err.exitCode === 1 && typeof err.stderr === 'string' && err.stderr.includes('At least one output file must be specified')) return outPaths; - throw err; - } - return outPaths; -} - -// https://stackoverflow.com/questions/32922226/extract-every-audio-and-subtitles-from-a-video-with-ffmpeg -export async function extractStreams({ filePath, customOutDir, streams, enableOverwriteOutput }: { - filePath: string, customOutDir: string | undefined, streams: FFprobeStream[], enableOverwriteOutput?: boolean | undefined, -}) { - const attachmentStreams = streams.filter((s) => s.codec_type === 'attachment'); - const nonAttachmentStreams = streams.filter((s) => s.codec_type !== 'attachment'); - - // TODO progress - - // Attachment streams are handled differently from normal streams - return [ - ...(await extractNonAttachmentStreams({ customOutDir, filePath, streams: nonAttachmentStreams, enableOverwriteOutput })), - ...(await extractAttachmentStreams({ customOutDir, filePath, streams: attachmentStreams, enableOverwriteOutput })), - ]; -} - async function renderThumbnail(filePath: string, timestamp: number, signal: AbortSignal) { const args = [ '-ss', String(timestamp), diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index 7434ac8a..3e024788 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -5,13 +5,13 @@ import pMap from 'p-map'; import invariant from 'tiny-invariant'; import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath, unlinkWithRetry, getFrameDuration } from '../util'; -import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine, getDuration, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, logStdoutStderr, runFfmpegConcat } from '../ffmpeg'; +import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine, getDuration, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, logStdoutStderr, runFfmpegConcat, RefuseOverwriteError, runFfmpeg } from '../ffmpeg'; import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams'; import { getSmartCutParams } from '../smartcut'; import { isDurationValid } from '../segments'; import { FFprobeStream } from '../../../../ffprobe'; import { AvoidNegativeTs, Html5ifyMode, PreserveMetadata } from '../../../../types'; -import { AllFilesMeta, Chapter, CopyfileStreams, CustomTagsByFile, ParamsByStreamId, SegmentToExport } from '../types'; +import { AllFilesMeta, Chapter, CopyfileStreams, CustomTagsByFile, LiteFFprobeStream, ParamsByStreamId, SegmentToExport } from '../types'; const { join, resolve, dirname } = window.require('path'); const { writeFile, mkdir, access, constants: { F_OK, W_OK } } = window.require('fs/promises'); @@ -674,8 +674,146 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea return outPath; }, [appendFfmpegCommandLog, filePath, treatOutputFileModifiedTimeAsStart]); + function getPreferredCodecFormat(stream: LiteFFprobeStream) { + const map = { + mp3: { format: 'mp3', ext: 'mp3' }, + opus: { format: 'opus', ext: 'opus' }, + vorbis: { format: 'ogg', ext: 'ogg' }, + h264: { format: 'mp4', ext: 'mp4' }, + hevc: { format: 'mp4', ext: 'mp4' }, + eac3: { format: 'eac3', ext: 'eac3' }, + + subrip: { format: 'srt', ext: 'srt' }, + mov_text: { format: 'mp4', ext: 'mp4' }, + + m4a: { format: 'ipod', ext: 'm4a' }, + aac: { format: 'adts', ext: 'aac' }, + jpeg: { format: 'image2', ext: 'jpeg' }, + png: { format: 'image2', ext: 'png' }, + + // TODO add more + // TODO allow user to change? + }; + + const match = map[stream.codec_name]; + if (match) return match; + + // default fallbacks: + if (stream.codec_type === 'video') return { ext: 'mkv', format: 'matroska' }; + if (stream.codec_type === 'audio') return { ext: 'mka', format: 'matroska' }; + if (stream.codec_type === 'subtitle') return { ext: 'mks', format: 'matroska' }; + if (stream.codec_type === 'data') return { ext: 'bin', format: 'data' }; // https://superuser.com/questions/1243257/save-data-stream + + return undefined; + } + + async function extractNonAttachmentStreams({ customOutDir, streams }: { + customOutDir?: string | undefined, streams: FFprobeStream[], + }) { + invariant(filePath != null); + if (streams.length === 0) return []; + + const outStreams = streams.map((s) => ({ + index: s.index, + codec: s.codec_name || s.codec_tag_string || s.codec_type, + type: s.codec_type, + format: getPreferredCodecFormat(s), + })) + .filter(({ format, index }) => format != null && index != null); + + // console.log(outStreams); + + + let streamArgs: string[] = []; + const outPaths = await pMap(outStreams, async ({ index, codec, type, format: { format, ext } }) => { + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); + if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError(); + + streamArgs = [ + ...streamArgs, + '-map', `0:${index}`, '-c', 'copy', '-f', format, '-y', outPath, + ]; + return outPath; + }, { concurrency: 1 }); + + const ffmpegArgs = [ + '-hide_banner', + + '-i', filePath, + ...streamArgs, + ]; + + appendFfmpegCommandLog(ffmpegArgs); + const { stdout } = await runFfmpeg(ffmpegArgs); + console.log(stdout.toString('utf8')); + + return outPaths; + } + + async function extractAttachmentStreams({ customOutDir, streams }: { + customOutDir?: string | undefined, streams: FFprobeStream[], + }) { + invariant(filePath != null); + if (streams.length === 0) return []; + + console.log('Extracting', streams.length, 'attachment streams'); + + let streamArgs: string[] = []; + const outPaths = await pMap(streams, async ({ index, codec_name: codec, codec_type: type }) => { + const ext = codec || 'bin'; + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); + if (outPath == null) throw new Error(); + if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError(); + + streamArgs = [ + ...streamArgs, + `-dump_attachment:${index}`, outPath, + ]; + return outPath; + }, { concurrency: 1 }); + + const ffmpegArgs = [ + '-y', + '-hide_banner', + '-loglevel', 'error', + ...streamArgs, + '-i', filePath, + ]; + + try { + appendFfmpegCommandLog(ffmpegArgs); + const { stdout } = await runFfmpeg(ffmpegArgs); + console.log(stdout.toString('utf8')); + } catch (err) { + // Unfortunately ffmpeg will exit with code 1 even though it's a success + // Note: This is kind of hacky: + if (err instanceof Error && 'exitCode' in err && 'stderr' in err && err.exitCode === 1 && typeof err.stderr === 'string' && err.stderr.includes('At least one output file must be specified')) return outPaths; + throw err; + } + return outPaths; + } + + // https://stackoverflow.com/questions/32922226/extract-every-audio-and-subtitles-from-a-video-with-ffmpeg + async function extractStreams({ customOutDir, streams }: { + customOutDir: string | undefined, streams: FFprobeStream[], + }) { + invariant(filePath != null); + + const attachmentStreams = streams.filter((s) => s.codec_type === 'attachment'); + const nonAttachmentStreams = streams.filter((s) => s.codec_type !== 'attachment'); + + // TODO progress + + // Attachment streams are handled differently from normal streams + return [ + ...(await extractNonAttachmentStreams({ customOutDir, streams: nonAttachmentStreams })), + ...(await extractAttachmentStreams({ customOutDir, streams: attachmentStreams })), + ]; + } + + return { - appendFfmpegCommandLog, cutMultiple, concatFiles, html5ify, html5ifyDummy, fixInvalidDuration, autoConcatCutSegments, + appendFfmpegCommandLog, cutMultiple, concatFiles, html5ify, html5ifyDummy, fixInvalidDuration, autoConcatCutSegments, extractStreams, }; }