diff --git a/src/App.jsx b/src/App.jsx index 132d3961..473a2dfd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -57,7 +57,7 @@ import { getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack, RefuseOverwriteError, readFrames, mapTimesToSegments, abortFfmpegs, } from './ffmpeg'; -import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback } from './util/streams'; +import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, isStreamThumbnail } from './util/streams'; import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore'; import { formatYouTube, getFrameCountRaw } from './edlFormats'; import { @@ -804,13 +804,14 @@ const App = memo(() => { } }, [setWorking, subtitleStreams, subtitlesByStreamId, filePath]); + const mainCopiedStreams = useMemo(() => mainStreams.filter((stream) => isCopyingStreamId(filePath, stream.index)), [filePath, isCopyingStreamId, mainStreams]); + const mainCopiedThumbnailStreams = useMemo(() => mainCopiedStreams.filter(isStreamThumbnail), [mainCopiedStreams]); + // Streams that are not copy enabled by default - const extraStreams = useMemo(() => mainStreams - .filter((stream) => !shouldCopyStreamByDefault(stream)), [mainStreams]); + const extraStreams = useMemo(() => mainStreams.filter((stream) => !shouldCopyStreamByDefault(stream)), [mainStreams]); // Extra streams that the user has not selected for copy - const nonCopiedExtraStreams = useMemo(() => extraStreams - .filter((stream) => !isCopyingStreamId(filePath, stream.index)), [extraStreams, filePath, isCopyingStreamId]); + const nonCopiedExtraStreams = useMemo(() => extraStreams.filter((stream) => !isCopyingStreamId(filePath, stream.index)), [extraStreams, filePath, isCopyingStreamId]); const exportExtraStreams = autoExportExtraStreams && nonCopiedExtraStreams.length > 0; @@ -819,14 +820,15 @@ const App = memo(() => { streamIds: Object.keys(streamIdsMap).filter(index => streamIdsMap[index]).map((streamIdStr) => parseInt(streamIdStr, 10)), })), [copyStreamIdsByFile]); - const numStreamsToCopy = copyFileStreams - .reduce((acc, { streamIds }) => acc + streamIds.length, 0); + // total number of streams to copy for ALL files + const numStreamsToCopy = copyFileStreams.reduce((acc, { streamIds }) => acc + streamIds.length, 0); const allFilesMeta = useMemo(() => ({ ...externalFilesMeta, [filePath]: mainFileMeta, }), [externalFilesMeta, filePath, mainFileMeta]); + // total number of streams for ALL files const numStreamsTotal = flatMap(Object.values(allFilesMeta), ({ streams }) => streams).length; const toggleStripAudio = useCallback(() => { @@ -1818,11 +1820,9 @@ const App = memo(() => { if (workingRef.current) return; try { - const enabledStreams = mainStreams.filter((stream) => isCopyingStreamId(filePath, stream.index)); - setWorking(i18n.t('Extracting all streams')); setStreamsSelectorShown(false); - const extractedPaths = await extractStreams({ customOutDir, filePath, streams: enabledStreams, enableOverwriteOutput }); + const extractedPaths = await extractStreams({ customOutDir, filePath, streams: mainCopiedStreams, enableOverwriteOutput }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: extractedPaths[0], text: i18n.t('All streams have been extracted as separate files') }); } catch (err) { if (err instanceof RefuseOverwriteError) { @@ -1834,7 +1834,7 @@ const App = memo(() => { } finally { setWorking(); } - }, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, isCopyingStreamId, mainStreams, setWorking]); + }, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainCopiedStreams, setWorking]); const detectSegments = useCallback(async ({ name, workingText, errorText, fn }) => { if (!filePath) return; @@ -2702,7 +2702,7 @@ const App = memo(() => { )} - + { const { t } = useTranslation(); @@ -52,6 +53,9 @@ const ExportConfirm = memo(({ const isMov = ffmpegIsMov(outFormat); const isIpod = outFormat === 'ipod'; + // some thumbnail streams (png,jpg etc) cannot always be cut correctly, so we warn if they try to. + const areWeCuttingProblematicStreams = areWeCutting && mainCopiedThumbnailStreams.length > 0; + const exportModeDescription = useMemo(() => ({ sesgments_to_chapters: t('Don\'t cut the file, but instead export an unmodified original which has chapters generated from segments'), merge: t('Auto merge segments to one file after export'), @@ -130,13 +134,13 @@ const ExportConfirm = memo(({
-

{t('Export options')}

-
    +

    {t('Export options')}

    +
      {selectedSegments.length !== nonFilteredSegments.length &&
    • {t('{{selectedSegments}} of {{nonFilteredSegments}} segments selected', { selectedSegments: selectedSegments.length, nonFilteredSegments: nonFilteredSegments.length })}
    • }
    • {t('Merge {{segments}} cut segments to one file?', { segments: selectedSegments.length })} - {effectiveExportMode === 'sesgments_to_chapters' && } + {effectiveExportMode === 'sesgments_to_chapters' && }
    • {t('Output container format:')} {renderOutFmt({ height: 20, maxWidth: 150 })} @@ -145,6 +149,8 @@ const ExportConfirm = memo(({
    • Input has {{ numStreamsTotal }} tracks - setStreamsSelectorShown(true)}>Keeping {{ numStreamsToCopy }} tracks + {areWeCuttingProblematicStreams && } + {areWeCuttingProblematicStreams &&
      Warning: Cutting thumbnail tracks is known to cause problems. Consider disabling track {{ trackNumber: mainCopiedThumbnailStreams[0].index + 1 }}.
      }
    • {t('Save output to path:')} {outputDir} @@ -156,10 +162,10 @@ const ExportConfirm = memo(({ )}
    -

    {t('Advanced options')}

    +

    {t('Advanced options')}

    {willMerge && ( -
      +
      • {t('Create chapters from merged segments? (slow)')} @@ -171,14 +177,15 @@ const ExportConfirm = memo(({
      )} -

      {t('Depending on your specific file/player, you may have to try different options for best results.')}

      +

      {t('Depending on your specific file/player, you may have to try different options for best results.')}

      -
        +
          {areWeCutting && ( <>
        • {t('Smart cut (experimental):')} + {enableSmartCut && }
        • {!enableSmartCut && (
        • @@ -204,14 +211,15 @@ const ExportConfirm = memo(({ {!enableSmartCut && (
        • - {t('Shift timestamps (avoid_negative_ts)')} + "avoid_negative_ts" + {!['make_zero', 'auto'].includes(avoidNegativeTs) &&
          {t('It\'s generally recommended to set this to one of: {{values}}', { values: '"auto", "make_zero"' })}
          }
        • )}
        diff --git a/src/StreamsSelector.jsx b/src/StreamsSelector.jsx index dded721b..e3d46ff0 100644 --- a/src/StreamsSelector.jsx +++ b/src/StreamsSelector.jsx @@ -1,6 +1,6 @@ import React, { memo, useState, useMemo, useCallback } from 'react'; -import { FaCheckCircle, FaPaperclip, FaVideo, FaVideoSlash, FaFileImport, FaVolumeUp, FaVolumeMute, FaBan, FaFileExport } from 'react-icons/fa'; +import { FaImage, FaCheckCircle, FaPaperclip, FaVideo, FaVideoSlash, FaFileImport, FaVolumeUp, FaVolumeMute, FaBan, FaFileExport } from 'react-icons/fa'; import { GoFileBinary } from 'react-icons/go'; import { FiEdit, FiCheck, FiTrash } from 'react-icons/fi'; import { MdSubtitles } from 'react-icons/md'; @@ -13,7 +13,7 @@ import { askForMetadataKey, showJson5Dialog } from './dialogs'; import { formatDuration } from './util/duration'; import { getStreamFps } from './ffmpeg'; import { deleteDispositionValue } from './util'; -import { getActiveDisposition } from './util/streams'; +import { getActiveDisposition, attachedPicDisposition } from './util/streams'; const activeColor = '#429777'; @@ -197,8 +197,13 @@ const Stream = memo(({ dispositionByStreamId, setDispositionByStreamId, filePath Icon = copyStream ? FaVolumeUp : FaVolumeMute; codecTypeHuman = t('audio'); } else if (stream.codec_type === 'video') { - Icon = copyStream ? FaVideo : FaVideoSlash; - codecTypeHuman = t('video'); + if (effectiveDisposition === attachedPicDisposition) { + Icon = copyStream ? FaImage : FaBan; + codecTypeHuman = t('thumbnail'); + } else { + Icon = copyStream ? FaVideo : FaVideoSlash; + codecTypeHuman = t('video'); + } } else if (stream.codec_type === 'subtitle') { Icon = copyStream ? MdSubtitles : FaBan; codecTypeHuman = t('subtitle'); diff --git a/src/hooks/useFfmpegOperations.js b/src/hooks/useFfmpegOperations.js index e9b01758..b3f36043 100644 --- a/src/hooks/useFfmpegOperations.js +++ b/src/hooks/useFfmpegOperations.js @@ -402,7 +402,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) { if (!needsSmartCut) await checkOverwrite(smartCutMainPartOutPath); - // for smart cut we need to use keyframe cut here + // for smart cut we need to use keyframe cut here, and no avoid_negative_ts await cutSingle({ cutFrom: encodeCutTo, cutTo, chaptersPath, outPath: smartCutMainPartOutPath, copyFileStreams: copyFileStreamsFiltered, keyframeCut: true, avoidNegativeTs: false, videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, customTagsByStreamId, dispositionByStreamId, videoTimebase, onProgress: onCutProgress, }); diff --git a/src/util/streams.js b/src/util/streams.js index 7ac94dea..e386f8b0 100644 --- a/src/util/streams.js +++ b/src/util/streams.js @@ -207,8 +207,10 @@ export function shouldCopyStreamByDefault(stream) { return true; } +export const attachedPicDisposition = 'attached_pic'; + export function isStreamThumbnail(stream) { - return stream && stream.disposition && stream.disposition.attached_pic === 1; + return stream && stream.codec_type === 'video' && stream.disposition?.[attachedPicDisposition] === 1; } export const getAudioStreams = (streams) => streams.filter(stream => stream.codec_type === 'audio');