Implement simple file and track metadata editor #496

pull/539/head
Mikael Finstad 6 years ago
parent d9717386ae
commit 5ac299a050

@ -29,6 +29,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic
- View segment details, export/import cut segments as CSV
- 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
## Example lossless use cases

@ -105,6 +105,8 @@ const App = memo(() => {
const [startTimeOffset, setStartTimeOffset] = useState(0);
const [filePath, setFilePath] = useState('');
const [externalStreamFiles, setExternalStreamFiles] = useState([]);
const [customTagsByFile, setCustomTagsByFile] = useState({});
const [customTagsByStreamId, setCustomTagsByStreamId] = useState({});
const [detectedFps, setDetectedFps] = useState();
const [mainStreams, setMainStreams] = useState([]);
const [mainVideoStream, setMainVideoStream] = useState();
@ -808,6 +810,8 @@ const App = memo(() => {
setStartTimeOffset(0);
setFilePath(''); // Setting video src="" prevents memory leak in chromium
setExternalStreamFiles([]);
setCustomTagsByFile({});
setCustomTagsByStreamId({});
setDetectedFps();
setMainStreams([]);
setMainVideoStream();
@ -1010,6 +1014,8 @@ const App = memo(() => {
ffmpegExperimental,
preserveMovData,
avoidNegativeTs,
customTagsByFile,
customTagsByStreamId,
});
if (outFiles.length > 1 && autoMerge) {
@ -1059,7 +1065,7 @@ const App = memo(() => {
setWorking();
setCutProgress();
}
}, [autoMerge, copyFileStreams, customOutDir, duration, effectiveRotation, exportExtraStreams, ffmpegExperimental, fileFormat, fileFormatData, filePath, handleCutFailed, isCustomFormatSelected, isRotationSet, keyframeCut, mainStreams, nonCopiedExtraStreams, outSegments, outputDir, shortestFlag, working, preserveMovData, avoidNegativeTs, numStreamsToCopy, hideAllNotifications, currentSegIndexSafe, invertCutSegments, autoDeleteMergedSegments, segmentsToChapters]);
}, [autoMerge, copyFileStreams, customOutDir, duration, effectiveRotation, exportExtraStreams, ffmpegExperimental, fileFormat, fileFormatData, filePath, handleCutFailed, isCustomFormatSelected, isRotationSet, keyframeCut, mainStreams, nonCopiedExtraStreams, outSegments, outputDir, shortestFlag, working, preserveMovData, avoidNegativeTs, numStreamsToCopy, hideAllNotifications, currentSegIndexSafe, invertCutSegments, autoDeleteMergedSegments, segmentsToChapters, customTagsByFile, customTagsByStreamId]);
const onExportPress = useCallback(async () => {
if (working || !filePath) return;
@ -1954,6 +1960,10 @@ const App = memo(() => {
setShortestFlag={setShortestFlag}
nonCopiedExtraStreams={nonCopiedExtraStreams}
AutoExportToggler={AutoExportToggler}
customTagsByFile={customTagsByFile}
setCustomTagsByFile={setCustomTagsByFile}
customTagsByStreamId={customTagsByStreamId}
setCustomTagsByStreamId={setCustomTagsByStreamId}
/>
</SideSheet>

@ -1,22 +1,164 @@
import React, { memo } from 'react';
import React, { memo, useState } from 'react';
import { FaVideo, FaVideoSlash, FaFileExport, FaFileImport, FaVolumeUp, FaVolumeMute, FaBan, FaTrashAlt, FaInfoCircle } from 'react-icons/fa';
import { GoFileBinary } from 'react-icons/go';
import { FiEdit, FiCheck, FiTrash } from 'react-icons/fi';
import { MdSubtitles } from 'react-icons/md';
import Swal from 'sweetalert2';
import { SegmentedControl } from 'evergreen-ui';
import { SegmentedControl, Dialog, Button } from 'evergreen-ui';
import withReactContent from 'sweetalert2-react-content';
import { useTranslation } from 'react-i18next';
import SyntaxHighlighter from 'react-syntax-highlighter';
import { tomorrow as style } from 'react-syntax-highlighter/dist/esm/styles/hljs';
import JSON5 from 'json5';
import { askForMetadataKey } from './dialogs';
import { formatDuration } from './util';
import { getStreamFps } from './ffmpeg';
const ReactSwal = withReactContent(Swal);
const activeColor = '#9f5f80';
const TagEditor = memo(({ existingTags, customTags, onTagChange, onTagReset }) => {
const { t } = useTranslation();
const [editingTag, setEditingTag] = useState();
const [editingTagVal, setEditingTagVal] = useState();
const [newTag, setNewTag] = useState();
const mergedTags = { ...existingTags, ...customTags, ...(newTag ? { [newTag]: '' } : {}) };
function onResetClick() {
onTagReset(editingTag);
setEditingTag();
setNewTag();
}
function onEditClick(tag) {
if (newTag) {
onTagChange(editingTag, editingTagVal);
setEditingTag();
setNewTag();
} else if (editingTag != null) {
if (editingTagVal !== existingTags[editingTag]) {
onTagChange(editingTag, editingTagVal);
setEditingTag();
} else { // If not actually changed, no need to update
onResetClick();
}
} else {
setEditingTag(tag);
setEditingTagVal(mergedTags[tag]);
}
}
function onSubmit(e) {
e.preventDefault();
onEditClick();
}
async function onAddPress() {
const tag = await askForMetadataKey();
if (!tag || Object.keys(mergedTags).includes(tag)) return;
setEditingTag(tag);
setEditingTagVal('');
setNewTag(tag);
}
return (
<>
<table style={{ color: 'black' }}>
<tbody>
{Object.keys(mergedTags).map((tag) => {
const editingThis = tag === editingTag;
const Icon = editingThis ? FiCheck : FiEdit;
const thisTagCustom = customTags[tag] != null;
const thisTagNew = existingTags[tag] == null;
return (
<tr key={tag}>
<td style={{ paddingRight: 20, color: thisTagNew ? activeColor : 'rgba(0,0,0,0.6)' }}>{tag}</td>
<td style={{ paddingTop: 5, paddingBottom: 5 }}>
{editingThis ? (
<form style={{ display: 'inline' }} onSubmit={onSubmit}><input placeholder={t('Enter value')} style={{ fontSize: 'inherit', borderRadius: 2, border: '1px solid black' }} value={editingTagVal || ''} type="text" onChange={(e) => setEditingTagVal(e.target.value)} /></form>
) : (
<span style={{ color: thisTagCustom ? activeColor : undefined, fontWeight: thisTagCustom ? 'bold' : undefined }}>{mergedTags[tag]}</span>
)}
{(editingTag == null || editingThis) && <Icon title={t('Edit')} role="button" size={17} style={{ paddingLeft: 5, verticalAlign: 'middle', color: activeColor }} onClick={() => onEditClick(tag)} />}
{editingThis && <FiTrash title={t('Reset')} role="button" size={18} style={{ paddingLeft: 5, verticalAlign: 'middle' }} onClick={onResetClick} />}
</td>
</tr>
);
})}
</tbody>
</table>
<Button style={{ marginTop: 10 }} iconBefore="plus" onClick={onAddPress}>Add metadata</Button>
</>
);
});
const EditFileDialog = memo(({ editingFile, externalFiles, mainFileFormatData, mainFilePath, customTagsByFile, setCustomTagsByFile }) => {
const formatData = editingFile === mainFilePath ? mainFileFormatData : externalFiles[editingFile].formatData;
const existingTags = formatData.tags;
const customTags = customTagsByFile[editingFile] || {};
function onTagChange(tag, value) {
setCustomTagsByFile((old) => ({ ...old, [editingFile]: { ...old[editingFile], [tag]: value } }));
}
function onTagReset(tag) {
setCustomTagsByFile((old) => {
const { [tag]: deleted, ...rest } = old[editingFile] || {};
return { ...old, [editingFile]: rest };
});
}
return <TagEditor existingTags={existingTags} customTags={customTags} onTagChange={onTagChange} onTagReset={onTagReset} />;
});
const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, path: editingFile }, externalFiles, mainFilePath, mainFileStreams, customTagsByStreamId, setCustomTagsByStreamId }) => {
const streams = editingFile === mainFilePath ? mainFileStreams : externalFiles[editingFile].streams;
const stream = streams.find((s) => s.index === editingStreamId);
if (!stream) return null;
const existingTags = stream.tags;
const customTags = (customTagsByStreamId[editingFile] || {})[editingStreamId] || {};
// This is deep!
function onTagChange(tag, value) {
setCustomTagsByStreamId((old) => ({
...old,
[editingFile]: {
...old[editingFile],
[editingStreamId]: {
...(old[editingFile] || {})[editingStreamId],
[tag]: value,
},
},
}));
}
function onTagReset(tag) {
setCustomTagsByStreamId((old) => {
const { [tag]: deleted, ...rest } = (old[editingFile] || {})[editingStreamId] || {};
return {
...old,
[editingFile]: {
...old[editingFile],
[editingStreamId]: rest,
},
};
});
}
return <TagEditor existingTags={existingTags} customTags={customTags} onTagChange={onTagChange} onTagReset={onTagReset} />;
});
function onInfoClick(s, title) {
const html = (
<SyntaxHighlighter language="javascript" style={style} customStyle={{ textAlign: 'left', maxHeight: 300, overflowY: 'auto', fontSize: 14 }}>
@ -31,7 +173,7 @@ function onInfoClick(s, title) {
});
}
const Stream = memo(({ stream, onToggle, copyStream, fileDuration }) => {
const Stream = memo(({ filePath, stream, onToggle, copyStream, fileDuration, setEditingStream }) => {
const { t } = useTranslation();
const bitrate = parseInt(stream.bit_rate, 10);
@ -68,18 +210,22 @@ const Stream = memo(({ stream, onToggle, copyStream, fileDuration }) => {
<td>{!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`}</td>
<td style={{ maxWidth: '2.5em', overflow: 'hidden' }}>{language}</td>
<td>{stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(2)}fps`}</td>
<td><FaInfoCircle role="button" onClick={() => onInfoClick(stream, t('Stream info'))} size={22} /></td>
<td>
<FaInfoCircle role="button" onClick={() => onInfoClick(stream, t('Track info'))} size={22} />
<FiEdit title={t('Edit track metadata')} role="button" size={20} style={{ padding: '0 5px' }} onClick={() => setEditingStream({ streamId: stream.index, path: filePath })} />
</td>
</tr>
);
});
const FileHeading = ({ path, formatData, onTrashClick }) => {
const FileHeading = ({ path, formatData, onTrashClick, onEditClick }) => {
const { t } = useTranslation();
return (
<div style={{ display: 'flex', marginBottom: 10, alignItems: 'center' }}>
<div title={path} style={{ wordBreak: 'break-all', fontWeight: 'bold' }}>{path.replace(/.*\/([^/]+)$/, '$1')}</div>
<FaInfoCircle role="button" onClick={() => onInfoClick(formatData, t('File info'))} size={20} style={{ padding: '0 5px 0 10px' }} />
{onEditClick && <FiEdit title={t('Edit file metadata')} role="button" size={20} style={{ padding: '0 5px' }} onClick={onEditClick} />}
{onTrashClick && <FaTrashAlt size={20} role="button" style={{ padding: '0 5px', cursor: 'pointer' }} onClick={onTrashClick} />}
</div>
);
@ -109,14 +255,16 @@ const tableStyle = { fontSize: 14, width: '100%' };
const fileStyle = { marginBottom: 10, backgroundColor: 'rgba(0,0,0,0.04)', padding: 5, borderRadius: 7 };
const StreamsSelector = memo(({
mainFilePath, mainFileFormatData, streams: existingStreams, isCopyingStreamId, toggleCopyStreamId,
mainFilePath, mainFileFormatData, streams: mainFileStreams, isCopyingStreamId, toggleCopyStreamId,
setCopyStreamIdsForPath, onExtractAllStreamsPress, externalFiles, setExternalFiles,
showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams,
AutoExportToggler,
AutoExportToggler, customTagsByFile, setCustomTagsByFile, customTagsByStreamId, setCustomTagsByStreamId,
}) => {
const [editingFile, setEditingFile] = useState();
const [editingStream, setEditingStream] = useState();
const { t } = useTranslation();
if (!existingStreams) return null;
if (!mainFileStreams) return null;
function getFormatDuration(formatData) {
if (!formatData || !formatData.duration) return undefined;
@ -136,78 +284,104 @@ const StreamsSelector = memo(({
const externalFilesEntries = Object.entries(externalFiles);
return (
<div style={{ color: 'black', padding: 10 }}>
<p>{t('Click to select which tracks to keep when exporting:')}</p>
<div style={fileStyle}>
<FileHeading path={mainFilePath} formatData={mainFileFormatData} />
<table style={tableStyle}>
<Thead />
<tbody>
{existingStreams.map((stream) => (
<Stream
key={stream.index}
stream={stream}
copyStream={isCopyingStreamId(mainFilePath, stream.index)}
onToggle={(streamId) => toggleCopyStreamId(mainFilePath, streamId)}
fileDuration={getFormatDuration(mainFileFormatData)}
/>
))}
</tbody>
</table>
</div>
<>
<div style={{ color: 'black', padding: 10 }}>
<p>{t('Click to select which tracks to keep when exporting:')}</p>
{externalFilesEntries.map(([path, { streams, formatData }]) => (
<div key={path} style={fileStyle}>
<FileHeading path={path} formatData={formatData} onTrashClick={() => removeFile(path)} />
<div style={fileStyle}>
{/* We only support editing main file metadata for now */}
<FileHeading path={mainFilePath} formatData={mainFileFormatData} onEditClick={() => setEditingFile(mainFilePath)} />
<table style={tableStyle}>
<Thead />
<tbody>
{streams.map((stream) => (
{mainFileStreams.map((stream) => (
<Stream
key={stream.index}
filePath={mainFilePath}
stream={stream}
copyStream={isCopyingStreamId(path, stream.index)}
onToggle={(streamId) => toggleCopyStreamId(path, streamId)}
fileDuration={getFormatDuration(formatData)}
copyStream={isCopyingStreamId(mainFilePath, stream.index)}
onToggle={(streamId) => toggleCopyStreamId(mainFilePath, streamId)}
setEditingStream={setEditingStream}
fileDuration={getFormatDuration(mainFileFormatData)}
/>
))}
</tbody>
</table>
</div>
))}
{externalFilesEntries.length > 0 && (
<div style={{ margin: '10px 0' }}>
<div>
{t('When tracks have different lengths, do you want to make the output file as long as the longest or the shortest track?')}
{externalFilesEntries.map(([path, { streams, formatData }]) => (
<div key={path} style={fileStyle}>
<FileHeading path={path} formatData={formatData} onTrashClick={() => removeFile(path)} />
<table style={tableStyle}>
<Thead />
<tbody>
{streams.map((stream) => (
<Stream
key={stream.index}
filePath={path}
stream={stream}
copyStream={isCopyingStreamId(path, stream.index)}
onToggle={(streamId) => toggleCopyStreamId(path, streamId)}
setEditingStream={setEditingStream}
fileDuration={getFormatDuration(formatData)}
/>
))}
</tbody>
</table>
</div>
))}
{externalFilesEntries.length > 0 && (
<div style={{ margin: '10px 0' }}>
<div>
{t('When tracks have different lengths, do you want to make the output file as long as the longest or the shortest track?')}
</div>
<SegmentedControl
options={[{ label: t('Longest'), value: 'longest' }, { label: t('Shortest'), value: 'shortest' }]}
value={shortestFlag ? 'shortest' : 'longest'}
onChange={value => setShortestFlag(value === 'shortest')}
/>
</div>
<SegmentedControl
options={[{ label: t('Longest'), value: 'longest' }, { label: t('Shortest'), value: 'shortest' }]}
value={shortestFlag ? 'shortest' : 'longest'}
onChange={value => setShortestFlag(value === 'shortest')}
/>
)}
<div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={showAddStreamSourceDialog}>
<FaFileImport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> {t('Include more tracks from other file')}
</div>
)}
<div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={showAddStreamSourceDialog}>
<FaFileImport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> {t('Include more tracks from other file')}
{nonCopiedExtraStreams.length > 0 && (
<div style={{ margin: '10px 0' }}>
{t('Discard or extract unprocessable tracks to separate files?')}
<AutoExportToggler />
</div>
)}
{externalFilesEntries.length === 0 && (
<div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={onExtractAllStreamsPress}>
<FaFileExport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> {t('Export each track as individual files')}
</div>
)}
</div>
{nonCopiedExtraStreams.length > 0 && (
<div style={{ margin: '10px 0' }}>
{t('Discard or extract unprocessable tracks to separate files?')}
<AutoExportToggler />
</div>
)}
<Dialog
title={t('Edit file metadata')}
isShown={editingFile != null}
hasCancel={false}
confirmLabel={t('Done')}
onCloseComplete={() => setEditingFile()}
>
<EditFileDialog editingFile={editingFile} externalFiles={externalFiles} mainFileFormatData={mainFileFormatData} mainFilePath={mainFilePath} customTagsByFile={customTagsByFile} setCustomTagsByFile={setCustomTagsByFile} />
</Dialog>
{externalFilesEntries.length === 0 && (
<div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={onExtractAllStreamsPress}>
<FaFileExport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> {t('Export each track as individual files')}
</div>
)}
</div>
<Dialog
title={t('Edit track {{trackNum}} metadata', { trackNum: editingStream && editingStream.streamId })}
isShown={!!editingStream}
hasCancel={false}
confirmLabel={t('Done')}
onCloseComplete={() => setEditingStream()}
>
<EditStreamDialog editingStream={editingStream} externalFiles={externalFiles} mainFilePath={mainFilePath} mainFileStreams={mainFileStreams} customTagsByStreamId={customTagsByStreamId} setCustomTagsByStreamId={setCustomTagsByStreamId} />
</Dialog>
</>
);
});

@ -177,6 +177,19 @@ async function askForSegmentDuration(fileDuration) {
return parseDuration(value);
}
export async function askForMetadataKey() {
const { value } = await Swal.fire({
title: i18n.t('Add metadata'),
text: i18n.t('Enter metadata key'),
input: 'text',
showCancelButton: true,
inputPlaceholder: 'metadata_key',
inputValidator: (v) => v.includes('=') && i18n.t('Invalid character(s) found in key'),
});
return value;
}
export async function createFixedDurationSegments(fileDuration) {
const segmentDuration = await askForSegmentDuration(fileDuration);
if (segmentDuration == null) return undefined;

@ -215,7 +215,7 @@ function getMovFlags(outFormat, preserveMovData) {
async function cut({
filePath, outFormat, cutFrom, cutTo, videoDuration, rotation, ffmpegExperimental,
onProgress, copyFileStreams, keyframeCut, outPath, appendFfmpegCommandLog, shortestFlag, preserveMovData,
avoidNegativeTs,
avoidNegativeTs, customTagsByFile, customTagsByStreamId,
}) {
const cuttingStart = isCuttingStart(cutFrom);
const cuttingEnd = isCuttingEnd(cutTo, videoDuration);
@ -248,6 +248,33 @@ async function cut({
const rotationArgs = rotation !== undefined ? ['-metadata:s:v:0', `rotate=${360 - rotation}`] : [];
function mapInputStreamIndexToOutputIndex(inputFilePath, inputFileStreamIndex) {
let streamCount = 0;
const found = copyFileStreamsFiltered.find(({ path: path2, streamIds }) => {
if (path2 === inputFilePath) return true;
streamCount += streamIds.length;
return false;
});
if (!found) return undefined; // Could happen if a tag has been edited on an external file, then the file was removed
return streamCount + inputFileStreamIndex;
}
const customTagsArgs = [
// We only support editing main file metadata for now
...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}`];
})))),
),
];
const ffmpegArgs = [
'-hide_banner',
// No progress if we set loglevel warning :(
@ -264,6 +291,8 @@ async function cut({
// https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata
...getMovFlags(outFormat, preserveMovData),
...customTagsArgs,
// See https://github.com/mifi/lossless-cut/issues/170
'-ignore_unknown',
@ -297,7 +326,11 @@ export async function cutMultiple({
customOutDir, filePath, segments, videoDuration, rotation,
onProgress, keyframeCut, copyFileStreams, outFormat, isCustomFormatSelected,
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, avoidNegativeTs,
customTagsByFile, customTagsByStreamId,
}) {
console.log('customTagsByFile', customTagsByFile);
console.log('customTagsByStreamId', customTagsByStreamId);
const singleProgresses = {};
function onSingleProgress(id, singleProgress) {
singleProgresses[id] = singleProgress;
@ -335,6 +368,8 @@ export async function cutMultiple({
ffmpegExperimental,
preserveMovData,
avoidNegativeTs,
customTagsByFile,
customTagsByStreamId,
});
outFiles.push(outPath);
@ -514,13 +549,20 @@ export async function mergeFiles({ paths, outDir, outPath, allStreams, outFormat
// https://blog.yo1.dog/fix-for-ffmpeg-protocol-not-on-whitelist-error-for-urls/
'-f', 'concat', '-safe', '0', '-protocol_whitelist', 'file,pipe', '-i', '-',
// Use the first file for metadata. Can only do this if allStreams (-map 0) is set, or else ffmpeg might output this input instead of the concat
...(allStreams ? ['-i', paths[0]] : []),
// Chapters?
...(ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []),
'-c', 'copy',
...(allStreams ? ['-map', '0'] : []),
'-map_metadata', '0',
// Use the file index 1 for metadata
// -map_metadata 0 with concat demuxer doesn't seem to preserve metadata when merging.
// Can only do this if allStreams (-map 0) is set
...(allStreams ? ['-map_metadata', '1'] : []),
// https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata
...getMovFlags(outFormat, preserveMovData),

Loading…
Cancel
Save