From 230b0352862c1559ee8e436abf795c94aae349b4 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Fri, 27 Aug 2021 17:16:22 +0700 Subject: [PATCH] implement per-stream disposition allows for adding cover art to videos #673 --- README.md | 2 ++ src/App.jsx | 7 ++++- src/StreamsSelector.jsx | 48 ++++++++++++++++++++++++++++---- src/hooks/useFfmpegOperations.js | 37 ++++++++++++++++-------- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0c472553..9b7af6c2 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic - Import segments from: MP4/MKV chapters, Text file, YouTube, CSV, CUE, XML (DaVinci, Final Cut Pro) - Video thumbnails and audio waveform - Edit file metadata and per-stream metadata +- Edit per-stream disposition - Cut with chapter marks - Annotate segments with tags @@ -60,6 +61,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic - Quickly cut a file by its MP4/MKV chapters - Quickly cut a [YouTube video](https://youtube-dl.org/) by its chapters (or music times from a comment) - Change the language of a file's audio/subtitle tracks +- Attach cover art to videos - Change author, title, GPS position, recording time of a video - Fix rotation of a video that has the wrong orientation flag set - Great for rotating phone videos that come out the wrong way without actually re-encoding the video. diff --git a/src/App.jsx b/src/App.jsx index 55b7bd2b..13a90118 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -119,6 +119,7 @@ const App = memo(() => { const [externalStreamFiles, setExternalStreamFiles] = useState([]); const [customTagsByFile, setCustomTagsByFile] = useState({}); const [customTagsByStreamId, setCustomTagsByStreamId] = useState({}); + const [dispositionByStreamId, setDispositionByStreamId] = useState({}); const [detectedFps, setDetectedFps] = useState(); const [mainStreams, setMainStreams] = useState([]); const [mainVideoStream, setMainVideoStream] = useState(); @@ -793,6 +794,7 @@ const App = memo(() => { setExternalStreamFiles([]); setCustomTagsByFile({}); setCustomTagsByStreamId({}); + setDisabledSegmentIds({}); setDetectedFps(); setMainStreams([]); setMainVideoStream(); @@ -1078,6 +1080,7 @@ const App = memo(() => { avoidNegativeTs, customTagsByFile, customTagsByStreamId, + dispositionByStreamId, }); if (outFiles.length > 1 && autoMerge) { @@ -1135,7 +1138,7 @@ const App = memo(() => { setWorking(); setCutProgress(); } - }, [numStreamsToCopy, enabledOutSegments, outSegTemplateOrDefault, generateOutSegFileNames, customOutDir, filePath, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, autoMerge, exportExtraStreams, fileFormatData, mainStreams, hideAllNotifications, outputDir, segmentsToChapters, invertCutSegments, isCustomFormatSelected, autoDeleteMergedSegments, preserveMetadataOnMerge, nonCopiedExtraStreams, handleCutFailed, isOutSegFileNamesValid, cutMultiple, autoMergeSegments, setWorking]); + }, [numStreamsToCopy, enabledOutSegments, outSegTemplateOrDefault, generateOutSegFileNames, customOutDir, filePath, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, dispositionByStreamId, autoMerge, exportExtraStreams, fileFormatData, mainStreams, hideAllNotifications, outputDir, segmentsToChapters, invertCutSegments, isCustomFormatSelected, autoDeleteMergedSegments, preserveMetadataOnMerge, nonCopiedExtraStreams, handleCutFailed, isOutSegFileNamesValid, cutMultiple, autoMergeSegments, setWorking]); const onExportPress = useCallback(async () => { if (!filePath || workingRef.current) return; @@ -2113,6 +2116,8 @@ const App = memo(() => { setCustomTagsByFile={setCustomTagsByFile} customTagsByStreamId={customTagsByStreamId} setCustomTagsByStreamId={setCustomTagsByStreamId} + dispositionByStreamId={dispositionByStreamId} + setDispositionByStreamId={setDispositionByStreamId} /> diff --git a/src/StreamsSelector.jsx b/src/StreamsSelector.jsx index c83b22a3..7159e123 100644 --- a/src/StreamsSelector.jsx +++ b/src/StreamsSelector.jsx @@ -4,7 +4,7 @@ import { FaVideo, FaVideoSlash, FaFileImport, FaVolumeUp, FaVolumeMute, FaBan, F import { GoFileBinary } from 'react-icons/go'; import { FiEdit, FiCheck, FiTrash } from 'react-icons/fi'; import { MdSubtitles } from 'react-icons/md'; -import { SortAscIcon, SortDescIcon, Dialog, Button, PlusIcon, Pane, ForkIcon } from 'evergreen-ui'; +import { Select, Heading, SortAscIcon, SortDescIcon, Dialog, Button, PlusIcon, Pane, ForkIcon } from 'evergreen-ui'; import { useTranslation } from 'react-i18next'; import { askForMetadataKey, showJson5Dialog } from './dialogs'; @@ -14,6 +14,8 @@ import { getStreamFps } from './ffmpeg'; const activeColor = '#9f5f80'; +const dispositionOptions = ['default', 'dub', 'original', 'comment', 'lyrics', 'karaoke', 'forced', 'hearing_impaired', 'visual_impaired', 'clean_effects', 'attached_pic', 'captions', 'descriptions', 'dependent', 'metadata']; + const TagEditor = memo(({ existingTags, customTags, onTagChange, onTagReset }) => { const { t } = useTranslation(); @@ -113,14 +115,21 @@ const EditFileDialog = memo(({ editingFile, externalFiles, mainFileFormatData, m return ; }); -const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, path: editingFile }, externalFiles, mainFilePath, mainFileStreams, customTagsByStreamId, setCustomTagsByStreamId }) => { +const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, path: editingFile }, externalFiles, mainFilePath, mainFileStreams, customTagsByStreamId, setCustomTagsByStreamId, dispositionByStreamId, setDispositionByStreamId }) => { const streams = editingFile === mainFilePath ? mainFileStreams : externalFiles[editingFile].streams; const stream = useMemo(() => streams.find((s) => s.index === editingStreamId), [streams, editingStreamId]); const existingTags = useMemo(() => (stream && stream.tags) || {}, [stream]); const customTags = useMemo(() => (customTagsByStreamId[editingFile] || {})[editingStreamId] || {}, [customTagsByStreamId, editingFile, editingStreamId]); - // This is deep! + const customDisposition = useMemo(() => (dispositionByStreamId[editingFile] || {})[editingStreamId], [dispositionByStreamId, editingFile, editingStreamId]); + const existingDisposition = useMemo(() => (stream && stream.disposition) || {}, [stream]); + const effectiveDisposition = customDisposition || existingDisposition; + const currentDisposition = (Object.entries(effectiveDisposition).find(([, value]) => value === 1) || [])[0]; + // console.log({ effectiveDisposition, currentDisposition }); + + const { t } = useTranslation(); + function onTagChange(tag, value) { setCustomTagsByStreamId((old) => ({ ...old, @@ -148,9 +157,37 @@ const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, pat }); } + function onCoverArtChange(e) { + const newDispositions = dispositionOptions.includes(e.target.value) ? { + [e.target.value]: 1, + } : undefined; + + // console.log(newDispositions); + + setDispositionByStreamId((old) => ({ + ...old, + [editingFile]: { + ...old[editingFile], + [editingStreamId]: newDispositions, + }, + })); + } + if (!stream) return null; - return ; + return ( + <> + {t('Track disposition')} + + Tags + + + ); }); function onInfoClick(json, title) { @@ -244,6 +281,7 @@ const StreamsSelector = memo(({ setCopyStreamIdsForPath, onExtractStreamPress, onExtractAllStreamsPress, externalFiles, setExternalFiles, showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams, AutoExportToggler, customTagsByFile, setCustomTagsByFile, customTagsByStreamId, setCustomTagsByStreamId, + dispositionByStreamId, setDispositionByStreamId, }) => { const [editingFile, setEditingFile] = useState(); const [editingStream, setEditingStream] = useState(); @@ -361,7 +399,7 @@ const StreamsSelector = memo(({ confirmLabel={t('Done')} onCloseComplete={() => setEditingStream()} > - + ); diff --git a/src/hooks/useFfmpegOperations.js b/src/hooks/useFfmpegOperations.js index 9d5601f9..f63bfcbc 100644 --- a/src/hooks/useFfmpegOperations.js +++ b/src/hooks/useFfmpegOperations.js @@ -51,7 +51,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) { outputDir, segments, segmentsFileNames, videoDuration, rotation, onProgress: onTotalProgress, keyframeCut, copyFileStreams, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, - customTagsByFile, customTagsByStreamId, + customTagsByFile, customTagsByStreamId, dispositionByStreamId, }) => { async function cutSingle({ cutFrom, cutTo, onProgress, outPath }) { const cuttingStart = isCuttingStart(cutFrom); @@ -104,22 +104,33 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) { return streamCount + copiedStreamIndex; } + // The structure is deep! file -> stream -> key -> value Example: { 'file.mp4': { 0: { key: 'value' } } } + const deepMap = (root, fn) => flatMapDeep( + Object.entries(root), ([path, streamsMap]) => ( + Object.entries(streamsMap || {}).map(([streamId, tagsMap]) => ( + Object.entries(tagsMap || {}).map(([key, value]) => fn(path, streamId, key, value))))), + ); + const customTagsArgs = [ - // We only support editing main file metadata for now + // Main file metadata: ...flatMap(Object.entries(customTagsByFile[filePath] || []), ([key, value]) => ['-metadata', `${key}=${value}`]), - // The structure is deep! Example: { 'file.mp4': { 0: { tag_name: 'Tag Value' } } } - ...flatMapDeep( - Object.entries(customTagsByStreamId), ([path, streamsMap]) => ( - Object.entries(streamsMap).map(([streamId, tagsMap]) => ( - Object.entries(tagsMap).map(([key, value]) => { - const outputIndex = mapInputStreamIndexToOutputIndex(path, parseInt(streamId, 10)); - if (outputIndex == null) return []; - return [`-metadata:s:${outputIndex}`, `${key}=${value}`]; - })))), - ), + // Example: { 'file.mp4': { 0: { tag_name: 'Tag Value' } } } + ...deepMap(customTagsByStreamId, (path, streamId, tag, value) => { + const outputIndex = mapInputStreamIndexToOutputIndex(path, parseInt(streamId, 10)); + if (outputIndex == null) return []; + return [`-metadata:s:${outputIndex}`, `${tag}=${value}`]; + }), ]; + // Example: { 'file.mp4': { 0: { attached_pic: 1 } } } + const customDispositionArgs = deepMap(dispositionByStreamId, (path, streamId, disposition, value) => { + if (value !== 1) return []; + const outputIndex = mapInputStreamIndexToOutputIndex(path, parseInt(streamId, 10)); + if (outputIndex == null) return []; + return [`-disposition:${outputIndex}`, String(disposition)]; + }); + const ffmpegArgs = [ '-hide_banner', // No progress if we set loglevel warning :( @@ -138,6 +149,8 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) { ...customTagsArgs, + ...customDispositionArgs, + // See https://github.com/mifi/lossless-cut/issues/170 '-ignore_unknown',