implement per-stream disposition

allows for adding cover art to videos #673
pull/841/head
Mikael Finstad 5 years ago
parent cd57d6cbe3
commit 230b035286
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -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.

@ -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}
/>
</SideSheet>

@ -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 <TagEditor existingTags={existingTags} customTags={customTags} onTagChange={onTagChange} onTagReset={onTagReset} />;
});
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 <TagEditor existingTags={existingTags} customTags={customTags} onTagChange={onTagChange} onTagReset={onTagReset} />;
return (
<>
<Heading marginBottom={5}>{t('Track disposition')}</Heading>
<Select marginBottom={20} value={currentDisposition || ''} onChange={onCoverArtChange}>
<option value="">{t('Unchanged')}</option>
{dispositionOptions.map((key) => (
<option key={key} value={key}>{key}</option>
))}
</Select>
<Heading>Tags</Heading>
<TagEditor existingTags={existingTags} customTags={customTags} onTagChange={onTagChange} onTagReset={onTagReset} />
</>
);
});
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()}
>
<EditStreamDialog editingStream={editingStream} externalFiles={externalFiles} mainFilePath={mainFilePath} mainFileStreams={mainFileStreams} customTagsByStreamId={customTagsByStreamId} setCustomTagsByStreamId={setCustomTagsByStreamId} />
<EditStreamDialog editingStream={editingStream} externalFiles={externalFiles} mainFilePath={mainFilePath} mainFileStreams={mainFileStreams} customTagsByStreamId={customTagsByStreamId} setCustomTagsByStreamId={setCustomTagsByStreamId} dispositionByStreamId={dispositionByStreamId} setDispositionByStreamId={setDispositionByStreamId} />
</Dialog>
</>
);

@ -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',

Loading…
Cancel
Save