kopia lustrzana https://github.com/mifi/lossless-cut
allow overriding per-stream options
means we can no longer use ffmpeg's default mapping for mp4/mov, use vtag hvc1 instead of the default unsupported hev1 fixes #1032pull/901/head
rodzic
bd50d25b85
commit
01c9ebd1c7
99
src/App.jsx
99
src/App.jsx
|
@ -49,20 +49,21 @@ import { loadMifiLink } from './mifi';
|
|||
import { controlsBackground } from './colors';
|
||||
import { captureFrameFromTag, captureFrameFfmpeg } from './capture-frame';
|
||||
import {
|
||||
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
|
||||
getStreamFps, isCuttingStart, isCuttingEnd,
|
||||
readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails,
|
||||
extractStreams, runStartupCheck,
|
||||
isAudioDefinitelyNotSupported, isIphoneHevc, tryMapChaptersToEdl,
|
||||
isIphoneHevc, tryMapChaptersToEdl,
|
||||
getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack,
|
||||
} from './ffmpeg';
|
||||
import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, defaultProcessedCodecTypes, isAudioDefinitelyNotSupported, doesPlayerSupportFile } from './util/streams';
|
||||
import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore';
|
||||
import { formatYouTube, getTimeFromFrameNum as getTimeFromFrameNumRaw, getFrameCountRaw } from './edlFormats';
|
||||
import {
|
||||
getOutPath, toast, errorToast, handleError, setFileNameTitle, getOutDir, getFileDir, withBlur,
|
||||
checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, doesPlayerSupportFile,
|
||||
checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer,
|
||||
isDurationValid, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate,
|
||||
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
|
||||
deleteFiles, isStreamThumbnail, getAudioStreams, getVideoStreams, isOutOfSpaceError, shuffleArray,
|
||||
deleteFiles, isOutOfSpaceError, shuffleArray,
|
||||
} from './util';
|
||||
import { formatDuration } from './util/duration';
|
||||
import { adjustRate } from './util/rate-calculator';
|
||||
|
@ -109,18 +110,16 @@ const App = memo(() => {
|
|||
const [playing, setPlaying] = useState(false);
|
||||
const [playerTime, setPlayerTime] = useState();
|
||||
const [duration, setDuration] = useState();
|
||||
const [fileFormatData, setFileFormatData] = useState();
|
||||
const [chapters, setChapters] = useState();
|
||||
const [rotation, setRotation] = useState(360);
|
||||
const [cutProgress, setCutProgress] = useState();
|
||||
const [startTimeOffset, setStartTimeOffset] = useState(0);
|
||||
const [filePath, setFilePath] = useState('');
|
||||
const [externalStreamFiles, setExternalStreamFiles] = useState([]);
|
||||
const [externalFilesMeta, setExternalFilesMeta] = useState({});
|
||||
const [customTagsByFile, setCustomTagsByFile] = useState({});
|
||||
const [customTagsByStreamId, setCustomTagsByStreamId] = useState({});
|
||||
const [dispositionByStreamId, setDispositionByStreamId] = useState({});
|
||||
const [detectedFps, setDetectedFps] = useState();
|
||||
const [mainStreams, setMainStreams] = useState([]);
|
||||
const [mainFileMeta, setMainFileMeta] = useState({ streams: [], formatData: {} });
|
||||
const [mainVideoStream, setMainVideoStream] = useState();
|
||||
const [mainAudioStream, setMainAudioStream] = useState();
|
||||
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({});
|
||||
|
@ -674,7 +673,7 @@ const App = memo(() => {
|
|||
return { cancel: false, newCustomOutDir };
|
||||
}, [customOutDir, setCustomOutDir]);
|
||||
|
||||
const userConcatFiles = useCallback(async ({ paths, includeAllStreams, fileFormat: fileFormat2, isCustomFormatSelected: isCustomFormatSelected2 }) => {
|
||||
const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: fileFormat2, isCustomFormatSelected: isCustomFormatSelected2 }) => {
|
||||
if (workingRef.current) return;
|
||||
try {
|
||||
setConcatDialogVisible(false);
|
||||
|
@ -695,7 +694,7 @@ const App = memo(() => {
|
|||
}
|
||||
|
||||
// console.log('merge', paths);
|
||||
await concatFiles({ paths, outPath, outDir, fileFormat: fileFormat2, includeAllStreams, ffmpegExperimental, onProgress: setCutProgress, preserveMovData, movFastStart, preserveMetadataOnMerge, chapters: chaptersFromSegments });
|
||||
await concatFiles({ paths, outPath, outDir, fileFormat: fileFormat2, includeAllStreams, streams, ffmpegExperimental, onProgress: setCutProgress, preserveMovData, movFastStart, preserveMetadataOnMerge, chapters: chaptersFromSegments });
|
||||
openDirToast({ icon: 'success', dirPath: outDir, text: i18n.t('Files merged!') });
|
||||
} catch (err) {
|
||||
if (isOutOfSpaceError(err)) {
|
||||
|
@ -743,6 +742,10 @@ const App = memo(() => {
|
|||
!!(copyStreamIdsByFile[path] || {})[streamId]
|
||||
), [copyStreamIdsByFile]);
|
||||
|
||||
const mainStreams = useMemo(() => mainFileMeta.streams, [mainFileMeta.streams]);
|
||||
const mainFileFormatData = useMemo(() => mainFileMeta.formatData, [mainFileMeta.formatData]);
|
||||
const mainFileChapters = useMemo(() => mainFileMeta.chapters, [mainFileMeta.chapters]);
|
||||
|
||||
const copyAnyAudioTrack = useMemo(() => mainStreams.some(stream => isCopyingStreamId(filePath, stream.index) && stream.codec_type === 'audio'), [filePath, isCopyingStreamId, mainStreams]);
|
||||
|
||||
const subtitleStreams = useMemo(() => mainStreams.filter((stream) => stream.codec_type === 'subtitle'), [mainStreams]);
|
||||
|
@ -783,16 +786,18 @@ const App = memo(() => {
|
|||
|
||||
const copyFileStreams = useMemo(() => Object.entries(copyStreamIdsByFile).map(([path, streamIdsMap]) => ({
|
||||
path,
|
||||
streamIds: Object.keys(streamIdsMap).filter(index => streamIdsMap[index]),
|
||||
streamIds: Object.keys(streamIdsMap).filter(index => streamIdsMap[index]).map((streamIdStr) => parseInt(streamIdStr, 10)),
|
||||
})), [copyStreamIdsByFile]);
|
||||
|
||||
const numStreamsToCopy = copyFileStreams
|
||||
.reduce((acc, { streamIds }) => acc + streamIds.length, 0);
|
||||
|
||||
const numStreamsTotal = [
|
||||
...mainStreams,
|
||||
...flatMap(Object.values(externalStreamFiles), ({ streams }) => streams),
|
||||
].length;
|
||||
const allFilesMeta = useMemo(() => ({
|
||||
...externalFilesMeta,
|
||||
[filePath]: mainFileMeta,
|
||||
}), [externalFilesMeta, filePath, mainFileMeta]);
|
||||
|
||||
const numStreamsTotal = flatMap(Object.values(allFilesMeta), ({ streams }) => streams).length;
|
||||
|
||||
const toggleStripAudio = useCallback(() => {
|
||||
setCopyStreamIdsForPath(filePath, (old) => {
|
||||
|
@ -888,19 +893,17 @@ const App = memo(() => {
|
|||
setCutStartTimeManual();
|
||||
setCutEndTimeManual();
|
||||
setFileFormat();
|
||||
setFileFormatData();
|
||||
setChapters();
|
||||
setDetectedFileFormat();
|
||||
setRotation(360);
|
||||
setCutProgress();
|
||||
setStartTimeOffset(0);
|
||||
setFilePath(''); // Setting video src="" prevents memory leak in chromium
|
||||
setExternalStreamFiles([]);
|
||||
setExternalFilesMeta({});
|
||||
setCustomTagsByFile({});
|
||||
setCustomTagsByStreamId({});
|
||||
setDispositionByStreamId({});
|
||||
setDetectedFps();
|
||||
setMainStreams([]);
|
||||
setMainFileMeta({ streams: [], formatData: [] });
|
||||
setMainVideoStream();
|
||||
setMainAudioStream();
|
||||
setCopyStreamIdsByFile({});
|
||||
|
@ -1153,17 +1156,17 @@ const App = memo(() => {
|
|||
const state = {
|
||||
filePath,
|
||||
fileFormat,
|
||||
externalStreamFiles,
|
||||
setExternalFilesMeta,
|
||||
mainStreams,
|
||||
copyStreamIdsByFile,
|
||||
cutSegments: cutSegments.map(s => ({ start: s.start, end: s.end })),
|
||||
fileFormatData,
|
||||
mainFileFormatData,
|
||||
rotation,
|
||||
shortestFlag,
|
||||
};
|
||||
|
||||
openSendReportDialog(err, state);
|
||||
}, [copyStreamIdsByFile, cutSegments, externalStreamFiles, fileFormat, fileFormatData, filePath, mainStreams, rotation, shortestFlag]);
|
||||
}, [copyStreamIdsByFile, cutSegments, setExternalFilesMeta, fileFormat, mainFileFormatData, filePath, mainStreams, rotation, shortestFlag]);
|
||||
|
||||
const handleCutFailed = useCallback(async (err) => {
|
||||
const sendErrorReport = await showCutFailedDialog({ detectedFileFormat });
|
||||
|
@ -1205,6 +1208,7 @@ const App = memo(() => {
|
|||
videoDuration: duration,
|
||||
rotation: isRotationSet ? effectiveRotation : undefined,
|
||||
copyFileStreams,
|
||||
allFilesMeta,
|
||||
keyframeCut,
|
||||
segments: segmentsToExport,
|
||||
segmentsFileNames: outSegFileNames,
|
||||
|
@ -1245,7 +1249,7 @@ const App = memo(() => {
|
|||
const msgs = [i18n.t('Done! Note: cutpoints may be inaccurate. Make sure you test the output files in your desired player/editor before you delete the source. If output does not look right, see the HELP page.')];
|
||||
|
||||
// https://github.com/mifi/lossless-cut/issues/329
|
||||
if (isIphoneHevc(fileFormatData, mainStreams)) msgs.push(i18n.t('There is a known issue with cutting iPhone HEVC videos. The output file may not work in all players.'));
|
||||
if (isIphoneHevc(mainFileFormatData, mainStreams)) msgs.push(i18n.t('There is a known issue with cutting iPhone HEVC videos. The output file may not work in all players.'));
|
||||
|
||||
if (exportExtraStreams) {
|
||||
try {
|
||||
|
@ -1275,7 +1279,7 @@ const App = memo(() => {
|
|||
setWorking();
|
||||
setCutProgress();
|
||||
}
|
||||
}, [numStreamsToCopy, setWorking, segmentsToChaptersOnly, enabledSegments, outSegTemplateOrDefault, generateOutSegFileNames, segmentsToExport, getOutSegError, cutMultiple, outputDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, dispositionByStreamId, willMerge, fileFormatData, mainStreams, exportExtraStreams, hideAllNotifications, segmentsToChapters, invertCutSegments, autoConcatCutSegments, customOutDir, isCustomFormatSelected, autoDeleteMergedSegments, preserveMetadataOnMerge, filePath, nonCopiedExtraStreams, handleCutFailed]);
|
||||
}, [numStreamsToCopy, setWorking, segmentsToChaptersOnly, enabledSegments, outSegTemplateOrDefault, generateOutSegFileNames, segmentsToExport, getOutSegError, cutMultiple, outputDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, dispositionByStreamId, willMerge, mainFileFormatData, mainStreams, exportExtraStreams, hideAllNotifications, segmentsToChapters, invertCutSegments, autoConcatCutSegments, customOutDir, isCustomFormatSelected, autoDeleteMergedSegments, preserveMetadataOnMerge, filePath, nonCopiedExtraStreams, handleCutFailed]);
|
||||
|
||||
const onExportPress = useCallback(async () => {
|
||||
if (!filePath || workingRef.current) return;
|
||||
|
@ -1407,16 +1411,14 @@ const App = memo(() => {
|
|||
|
||||
const fileFormatNew = await getSmarterOutFormat(fp, fileMeta.format);
|
||||
|
||||
const { streams } = fileMeta;
|
||||
|
||||
// console.log(streams, fileMeta.format, fileFormat);
|
||||
|
||||
if (!fileFormatNew) throw new Error('Unable to determine file format');
|
||||
|
||||
const timecode = autoLoadTimecode ? getTimecodeFromStreams(streams) : undefined;
|
||||
const timecode = autoLoadTimecode ? getTimecodeFromStreams(fileMeta.streams) : undefined;
|
||||
|
||||
const videoStreams = getVideoStreams(streams);
|
||||
const audioStreams = getAudioStreams(streams);
|
||||
const videoStreams = getRealVideoStreams(fileMeta.streams);
|
||||
const audioStreams = getAudioStreams(fileMeta.streams);
|
||||
|
||||
const videoStream = videoStreams[0];
|
||||
const audioStream = audioStreams[0];
|
||||
|
@ -1426,22 +1428,14 @@ const App = memo(() => {
|
|||
|
||||
const detectedFpsNew = haveVideoStream ? getStreamFps(videoStream) : undefined;
|
||||
|
||||
const shouldCopyStreamByDefault = (stream) => {
|
||||
if (!defaultProcessedCodecTypes.includes(stream.codec_type)) return false;
|
||||
// Don't enable thumbnail stream by default if we have a main video stream
|
||||
// It's been known to cause issues: https://github.com/mifi/lossless-cut/issues/308
|
||||
if (haveVideoStream && isStreamThumbnail(stream)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const copyStreamIdsForPathNew = fromPairs(streams.map((stream) => [
|
||||
const copyStreamIdsForPathNew = fromPairs(fileMeta.streams.map((stream) => [
|
||||
stream.index, shouldCopyStreamByDefault(stream),
|
||||
]));
|
||||
|
||||
if (timecode) setStartTimeOffset(timecode);
|
||||
if (detectedFpsNew != null) setDetectedFps(detectedFpsNew);
|
||||
|
||||
if (isAudioDefinitelyNotSupported(streams)) {
|
||||
if (isAudioDefinitelyNotSupported(fileMeta.streams)) {
|
||||
toast.fire({ icon: 'info', text: i18n.t('The audio track is not supported. You can convert to a supported format from the menu') });
|
||||
}
|
||||
|
||||
|
@ -1449,7 +1443,7 @@ const App = memo(() => {
|
|||
const hasLoadedExistingHtml5FriendlyFile = await checkAndSetExistingHtml5FriendlyFile();
|
||||
|
||||
// 'fastest' works with almost all video files
|
||||
if (!hasLoadedExistingHtml5FriendlyFile && !doesPlayerSupportFile(streams) && validDuration) {
|
||||
if (!hasLoadedExistingHtml5FriendlyFile && !doesPlayerSupportFile(fileMeta.streams) && validDuration) {
|
||||
await html5ifyAndLoadWithPreferences(cod, fp, 'fastest', haveVideoStream, haveAudioStream);
|
||||
}
|
||||
|
||||
|
@ -1480,15 +1474,13 @@ const App = memo(() => {
|
|||
if (!validDuration) toast.fire({ icon: 'warning', timer: 10000, text: i18n.t('This file does not have a valid duration. This may cause issues. You can try to fix the file\'s duration from the File menu') });
|
||||
|
||||
batchedUpdates(() => {
|
||||
setMainStreams(streams);
|
||||
setMainFileMeta({ streams: fileMeta.streams, formatData: fileMeta.format, chapters: fileMeta.chapters });
|
||||
setMainVideoStream(videoStream);
|
||||
setMainAudioStream(audioStream);
|
||||
setCopyStreamIdsForPath(fp, () => copyStreamIdsForPathNew);
|
||||
setFileNameTitle(fp);
|
||||
setFileFormat(outFormatLocked || fileFormatNew);
|
||||
setDetectedFileFormat(fileFormatNew);
|
||||
setFileFormatData(fileMeta.format);
|
||||
setChapters(fileMeta.chapters);
|
||||
|
||||
// This needs to be last, because it triggers <video> to load the video
|
||||
// If not, onVideoError might be triggered before setWorking() has been cleared.
|
||||
|
@ -1836,12 +1828,12 @@ const App = memo(() => {
|
|||
}, [customOutDir, filePath, mainStreams, outputDir, setWorking]);
|
||||
|
||||
const addStreamSourceFile = useCallback(async (path) => {
|
||||
if (externalStreamFiles[path]) return;
|
||||
const { streams, format: formatData } = await readFileMeta(path);
|
||||
// console.log('streams', streams);
|
||||
setExternalStreamFiles(old => ({ ...old, [path]: { streams, formatData } }));
|
||||
setCopyStreamIdsForPath(path, () => fromPairs(streams.map(({ index }) => [index, true])));
|
||||
}, [externalStreamFiles, setCopyStreamIdsForPath]);
|
||||
if (allFilesMeta[path]) return;
|
||||
const fileMeta = await readFileMeta(path);
|
||||
// console.log('streams', fileMeta.streams);
|
||||
setExternalFilesMeta((old) => ({ ...old, [path]: { streams: fileMeta.streams, formatData: fileMeta.format, chapters: fileMeta.chapters } }));
|
||||
setCopyStreamIdsForPath(path, () => fromPairs(fileMeta.streams.map(({ index }) => [index, true])));
|
||||
}, [allFilesMeta, setCopyStreamIdsForPath]);
|
||||
|
||||
const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]);
|
||||
|
||||
|
@ -2404,10 +2396,11 @@ const App = memo(() => {
|
|||
>
|
||||
<StreamsSelector
|
||||
mainFilePath={filePath}
|
||||
mainFileFormatData={fileFormatData}
|
||||
mainFileChapters={chapters}
|
||||
externalFiles={externalStreamFiles}
|
||||
setExternalFiles={setExternalStreamFiles}
|
||||
mainFileFormatData={mainFileFormatData}
|
||||
mainFileChapters={mainFileChapters}
|
||||
allFilesMeta={allFilesMeta}
|
||||
externalFilesMeta={externalFilesMeta}
|
||||
setExternalFilesMeta={setExternalFilesMeta}
|
||||
showAddStreamSourceDialog={showAddStreamSourceDialog}
|
||||
streams={mainStreams}
|
||||
isCopyingStreamId={isCopyingStreamId}
|
||||
|
|
|
@ -100,8 +100,8 @@ const TagEditor = memo(({ existingTags, customTags, onTagChange, onTagReset }) =
|
|||
);
|
||||
});
|
||||
|
||||
const EditFileDialog = memo(({ editingFile, externalFiles, mainFileFormatData, mainFilePath, customTagsByFile, setCustomTagsByFile }) => {
|
||||
const formatData = editingFile === mainFilePath ? mainFileFormatData : externalFiles[editingFile].formatData;
|
||||
const EditFileDialog = memo(({ editingFile, allFilesMeta, customTagsByFile, setCustomTagsByFile }) => {
|
||||
const { formatData } = allFilesMeta[editingFile];
|
||||
const existingTags = formatData.tags || {};
|
||||
const customTags = customTagsByFile[editingFile] || {};
|
||||
|
||||
|
@ -119,8 +119,8 @@ 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, dispositionByStreamId, setDispositionByStreamId }) => {
|
||||
const streams = editingFile === mainFilePath ? mainFileStreams : externalFiles[editingFile].streams;
|
||||
const EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, path: editingFile }, allFilesMeta, customTagsByStreamId, setCustomTagsByStreamId, dispositionByStreamId, setDispositionByStreamId }) => {
|
||||
const { streams } = allFilesMeta[editingFile];
|
||||
const stream = useMemo(() => streams.find((s) => s.index === editingStreamId), [streams, editingStreamId]);
|
||||
|
||||
const existingTags = useMemo(() => (stream && stream.tags) || {}, [stream]);
|
||||
|
@ -325,7 +325,7 @@ const fileStyle = { marginBottom: 20, padding: 5, minWidth: '100%', overflowX: '
|
|||
|
||||
const StreamsSelector = memo(({
|
||||
mainFilePath, mainFileFormatData, streams: mainFileStreams, mainFileChapters, isCopyingStreamId, toggleCopyStreamId,
|
||||
setCopyStreamIdsForPath, onExtractStreamPress, onExtractAllStreamsPress, externalFiles, setExternalFiles,
|
||||
setCopyStreamIdsForPath, onExtractStreamPress, onExtractAllStreamsPress, allFilesMeta, externalFilesMeta, setExternalFilesMeta,
|
||||
showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams,
|
||||
AutoExportToggler, customTagsByFile, setCustomTagsByFile, customTagsByStreamId, setCustomTagsByStreamId,
|
||||
dispositionByStreamId, setDispositionByStreamId,
|
||||
|
@ -345,7 +345,7 @@ const StreamsSelector = memo(({
|
|||
|
||||
async function removeFile(path) {
|
||||
setCopyStreamIdsForPath(path, () => ({}));
|
||||
setExternalFiles((old) => {
|
||||
setExternalFilesMeta((old) => {
|
||||
const { [path]: val, ...rest } = old;
|
||||
return rest;
|
||||
});
|
||||
|
@ -365,7 +365,7 @@ const StreamsSelector = memo(({
|
|||
setCopyStreamIdsForPath(path, (old) => Object.fromEntries(Object.entries(old).map(([streamId]) => [streamId, enabled])));
|
||||
}
|
||||
|
||||
const externalFilesEntries = Object.entries(externalFiles);
|
||||
const externalFilesEntries = Object.entries(externalFilesMeta);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -452,7 +452,7 @@ const StreamsSelector = memo(({
|
|||
confirmLabel={t('Done')}
|
||||
onCloseComplete={() => setEditingFile()}
|
||||
>
|
||||
<EditFileDialog editingFile={editingFile} externalFiles={externalFiles} mainFileFormatData={mainFileFormatData} mainFilePath={mainFilePath} customTagsByFile={customTagsByFile} setCustomTagsByFile={setCustomTagsByFile} />
|
||||
<EditFileDialog editingFile={editingFile} allFilesMeta={allFilesMeta} customTagsByFile={customTagsByFile} setCustomTagsByFile={setCustomTagsByFile} />
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
|
@ -462,7 +462,7 @@ const StreamsSelector = memo(({
|
|||
confirmLabel={t('Done')}
|
||||
onCloseComplete={() => setEditingStream()}
|
||||
>
|
||||
<EditStreamDialog editingStream={editingStream} externalFiles={externalFiles} mainFilePath={mainFilePath} mainFileStreams={mainFileStreams} customTagsByStreamId={customTagsByStreamId} setCustomTagsByStreamId={setCustomTagsByStreamId} dispositionByStreamId={dispositionByStreamId} setDispositionByStreamId={setDispositionByStreamId} />
|
||||
<EditStreamDialog editingStream={editingStream} allFilesMeta={allFilesMeta} customTagsByStreamId={customTagsByStreamId} setCustomTagsByStreamId={setCustomTagsByStreamId} dispositionByStreamId={dispositionByStreamId} setDispositionByStreamId={setDispositionByStreamId} />
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -45,6 +45,7 @@ const ConcatDialog = memo(({
|
|||
const [paths, setPaths] = useState(initialPaths);
|
||||
const [includeAllStreams, setIncludeAllStreams] = useState(false);
|
||||
const [sortDesc, setSortDesc] = useState();
|
||||
const [fileMeta, setFileMeta] = useState();
|
||||
|
||||
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
||||
|
||||
|
@ -56,11 +57,13 @@ const ConcatDialog = memo(({
|
|||
let aborted = false;
|
||||
(async () => {
|
||||
const firstPath = initialPaths[0];
|
||||
setFileMeta();
|
||||
setFileFormat();
|
||||
setDetectedFileFormat();
|
||||
const fileMeta = await readFileMeta(firstPath);
|
||||
const fileFormatNew = await getSmarterOutFormat(firstPath, fileMeta.format);
|
||||
const fileMetaNew = await readFileMeta(firstPath);
|
||||
const fileFormatNew = await getSmarterOutFormat(firstPath, fileMetaNew.format);
|
||||
if (aborted) return;
|
||||
setFileMeta(fileMetaNew);
|
||||
setFileFormat(fileFormatNew);
|
||||
setDetectedFileFormat(fileFormatNew);
|
||||
})().catch(console.error);
|
||||
|
@ -86,6 +89,8 @@ const ConcatDialog = memo(({
|
|||
|
||||
const onOutputFormatUserChange = useCallback((newFormat) => setFileFormat(newFormat), [setFileFormat]);
|
||||
|
||||
const onConcatClick = useCallback(() => onConcat({ paths, includeAllStreams, streams: fileMeta.streams, fileFormat, isCustomFormatSelected }), [fileFormat, fileMeta, includeAllStreams, isCustomFormatSelected, onConcat, paths]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={t('Merge/concatenate files')}
|
||||
|
@ -98,7 +103,7 @@ const ConcatDialog = memo(({
|
|||
{fileFormat && detectedFileFormat && <OutputFormatSelect style={{ maxWidth: 150 }} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />}
|
||||
<Button iconBefore={sortDesc ? SortAlphabeticalDescIcon : SortAlphabeticalIcon} onClick={onSortClick}>{t('Sort items')}</Button>
|
||||
<Button onClick={onHide} style={{ marginLeft: 10 }}>Cancel</Button>
|
||||
<Button iconBefore={<AiOutlineMergeCells />} isLoading={detectedFileFormat == null} appearance="primary" onClick={() => onConcat({ paths, includeAllStreams, fileFormat, isCustomFormatSelected })}>{t('Merge!')}</Button>
|
||||
<Button iconBefore={<AiOutlineMergeCells />} isLoading={detectedFileFormat == null} appearance="primary" onClick={onConcatClick}>{t('Merge!')}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -5,7 +5,7 @@ import moment from 'moment';
|
|||
import i18n from 'i18next';
|
||||
import Timecode from 'smpte-timecode';
|
||||
|
||||
import { getOutPath, isDurationValid, getExtensionForFormat, isWindows, platform, getAudioStreams } from './util';
|
||||
import { getOutPath, isDurationValid, getExtensionForFormat, isWindows, platform } from './util';
|
||||
|
||||
const execa = window.require('execa');
|
||||
const { join } = window.require('path');
|
||||
|
@ -268,7 +268,7 @@ export async function readFileMeta(filePath) {
|
|||
'-of', 'json', '-show_chapters', '-show_format', '-show_entries', 'stream', '-i', filePath, '-hide_banner',
|
||||
]);
|
||||
|
||||
const { streams, format, chapters } = JSON.parse(stdout);
|
||||
const { streams = [], format = {}, chapters = [] } = JSON.parse(stdout);
|
||||
return { format, streams, chapters };
|
||||
} catch (err) {
|
||||
// Windows will throw error with code ENOENT if format detection fails.
|
||||
|
@ -544,23 +544,9 @@ export async function captureFrame({ timestamp, videoPath, outPath }) {
|
|||
await execa(ffmpegPath, args, { encoding: null });
|
||||
}
|
||||
|
||||
// https://www.ffmpeg.org/doxygen/3.2/libavutil_2utils_8c_source.html#l00079
|
||||
export const defaultProcessedCodecTypes = [
|
||||
'video',
|
||||
'audio',
|
||||
'subtitle',
|
||||
'attachment',
|
||||
];
|
||||
|
||||
export const isMov = (format) => ['ismv', 'ipod', 'mp4', 'mov'].includes(format);
|
||||
|
||||
export function isAudioDefinitelyNotSupported(streams) {
|
||||
const audioStreams = getAudioStreams(streams);
|
||||
if (audioStreams.length === 0) return false;
|
||||
// TODO this could be improved
|
||||
return audioStreams.every(stream => ['ac3'].includes(stream.codec_name));
|
||||
}
|
||||
|
||||
export function isIphoneHevc(format, streams) {
|
||||
if (!streams.some((s) => s.codec_name === 'hevc')) return false;
|
||||
const makeTag = format.tags && format.tags['com.apple.quicktime.make'];
|
||||
|
|
|
@ -5,7 +5,8 @@ import sum from 'lodash/sum';
|
|||
import pMap from 'p-map';
|
||||
|
||||
import { getOutPath, transferTimestamps, getOutFileExtension, getOutDir, isMac, deleteDispositionValue, getHtml5ifiedPath } from '../util';
|
||||
import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments } from '../ffmpeg';
|
||||
import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments, readFileMeta } from '../ffmpeg';
|
||||
import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams';
|
||||
|
||||
const execa = window.require('execa');
|
||||
const { join, resolve } = window.require('path');
|
||||
|
@ -52,6 +53,7 @@ function getMatroskaFlags() {
|
|||
|
||||
const getChaptersInputArgs = (ffmetadataPath) => (ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []);
|
||||
|
||||
|
||||
function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
||||
const optionalTransferTimestamps = useCallback(async (...args) => {
|
||||
if (enableTransferTimestamps) await transferTimestamps(...args);
|
||||
|
@ -61,7 +63,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
|
||||
const cutMultiple = useCallback(async ({
|
||||
outputDir, segments, segmentsFileNames, videoDuration, rotation,
|
||||
onProgress: onTotalProgress, keyframeCut, copyFileStreams, outFormat,
|
||||
onProgress: onTotalProgress, keyframeCut, copyFileStreams, allFilesMeta, outFormat,
|
||||
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs,
|
||||
customTagsByFile, customTagsByStreamId, dispositionByStreamId, chapters,
|
||||
}) => {
|
||||
|
@ -118,7 +120,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
if (!foundFile) return undefined; // Could happen if a tag has been edited on an external file, then the file was removed
|
||||
|
||||
// Then add the index of the current stream index to the count
|
||||
const copiedStreamIndex = foundFile.streamIds.indexOf(String(inputFileStreamIndex));
|
||||
const copiedStreamIndex = foundFile.streamIds.indexOf(inputFileStreamIndex);
|
||||
if (copiedStreamIndex === -1) return undefined; // Could happen if a tag has been edited on a stream, but the stream is disabled
|
||||
return streamCount + copiedStreamIndex;
|
||||
}
|
||||
|
@ -145,6 +147,8 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
}),
|
||||
];
|
||||
|
||||
const mapStreamsArgs = getMapStreamsArgs({ copyFileStreams: copyFileStreamsFiltered, allFilesMeta, outFormat });
|
||||
|
||||
// Example: { 'file.mp4': { 0: { attached_pic: 1 } } }
|
||||
const customDispositionArgs = lessDeepMap(dispositionByStreamId, (path, streamId, disposition) => {
|
||||
if (disposition == null) return [];
|
||||
|
@ -166,7 +170,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
|
||||
...(shortestFlag ? ['-shortest'] : []),
|
||||
|
||||
...flatMapDeep(copyFileStreamsFiltered, ({ streamIds }, fileIndex) => streamIds.map(streamId => ['-map', `${fileIndex}:${streamId}`])),
|
||||
...mapStreamsArgs,
|
||||
|
||||
'-map_metadata', '0',
|
||||
|
||||
|
@ -236,9 +240,11 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
}
|
||||
}, [filePath, optionalTransferTimestamps]);
|
||||
|
||||
const concatFiles = useCallback(async ({ paths, outDir, outPath, includeAllStreams, outFormat, ffmpegExperimental, onProgress = () => {}, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge }) => {
|
||||
const concatFiles = useCallback(async ({ paths, outDir, outPath, includeAllStreams, streams, outFormat, ffmpegExperimental, onProgress = () => {}, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge }) => {
|
||||
console.log('Merging files', { paths }, 'to', outPath);
|
||||
|
||||
const firstPath = paths[0];
|
||||
|
||||
const durations = await pMap(paths, getDuration, { concurrency: 1 });
|
||||
const totalDuration = sum(durations);
|
||||
|
||||
|
@ -267,7 +273,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
let metadataSourceIndex;
|
||||
if (preserveMetadataOnMerge) {
|
||||
// If preserve metadata, add the first file (we will get metadata from this input)
|
||||
metadataSourceIndex = addInput(['-i', paths[0]]);
|
||||
metadataSourceIndex = addInput(['-i', firstPath]);
|
||||
}
|
||||
|
||||
let chaptersInputIndex;
|
||||
|
@ -276,14 +282,12 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
chaptersInputIndex = addInput(getChaptersInputArgs(chaptersPath));
|
||||
}
|
||||
|
||||
let map;
|
||||
if (includeAllStreams) map = ['-map', '0'];
|
||||
// If preserveMetadataOnMerge option is enabled, we need to explicitly map even if allStreams=false.
|
||||
// We cannot use the ffmpeg's automatic stream selection or else ffmpeg might use the metadata source input (index 1)
|
||||
// instead of the concat input (index 0)
|
||||
// https://ffmpeg.org/ffmpeg.html#Automatic-stream-selection
|
||||
else if (preserveMetadataOnMerge) map = ['-map', 'v:0?', '-map', 'a:0?', '-map', 's:0?'];
|
||||
else map = []; // ffmpeg default mapping
|
||||
const streamIdsToCopy = getStreamIdsToCopy({ streams, includeAllStreams });
|
||||
const mapStreamsArgs = getMapStreamsArgs({
|
||||
allFilesMeta: { [firstPath]: { streams } },
|
||||
copyFileStreams: [{ path: firstPath, streamIds: streamIdsToCopy }],
|
||||
outFormat,
|
||||
});
|
||||
|
||||
// Keep this similar to cutSingle()
|
||||
const ffmpegArgs = [
|
||||
|
@ -295,7 +299,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
|
||||
'-c', 'copy',
|
||||
|
||||
...map,
|
||||
...mapStreamsArgs,
|
||||
|
||||
// -map_metadata 0 with concat demuxer doesn't transfer metadata from the concat'ed file input (index 0) when merging.
|
||||
// So we use the first file file (index 1) for metadata
|
||||
|
@ -339,7 +343,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
if (chaptersPath) await fs.unlink(chaptersPath).catch((err) => console.error('Failed to delete', chaptersPath, err));
|
||||
}
|
||||
|
||||
await optionalTransferTimestamps(paths[0], outPath);
|
||||
await optionalTransferTimestamps(firstPath, outPath);
|
||||
}, [optionalTransferTimestamps]);
|
||||
|
||||
const autoConcatCutSegments = useCallback(async ({ customOutDir, isCustomFormatSelected, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge }) => {
|
||||
|
@ -349,7 +353,9 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
|
||||
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
|
||||
|
||||
await concatFiles({ paths: segmentPaths, outDir, outPath, outFormat, includeAllStreams: true, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });
|
||||
// need to re-read streams because may have changed
|
||||
const { streams } = await readFileMeta(segmentPaths[0]);
|
||||
await concatFiles({ paths: segmentPaths, outDir, outPath, outFormat, includeAllStreams: true, streams, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });
|
||||
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => fs.unlink(path), { concurrency: 5 });
|
||||
}, [concatFiles, filePath]);
|
||||
|
||||
|
|
19
src/util.js
19
src/util.js
|
@ -146,25 +146,6 @@ export function dragPreventer(ev) {
|
|||
ev.preventDefault();
|
||||
}
|
||||
|
||||
export function isStreamThumbnail(stream) {
|
||||
return stream && stream.disposition && stream.disposition.attached_pic === 1;
|
||||
}
|
||||
|
||||
export const getAudioStreams = (streams) => streams.filter(stream => stream.codec_type === 'audio');
|
||||
export const getVideoStreams = (streams) => streams.filter(stream => stream.codec_type === 'video' && !isStreamThumbnail(stream));
|
||||
|
||||
// With these codecs, the player will not give a playback error, but instead only play audio
|
||||
export function doesPlayerSupportFile(streams) {
|
||||
const videoStreams = getVideoStreams(streams);
|
||||
// Don't check audio formats, assume all is OK
|
||||
if (videoStreams.length === 0) return true;
|
||||
// If we have at least one video that is NOT of the unsupported formats, assume the player will be able to play it natively
|
||||
// https://github.com/mifi/lossless-cut/issues/595
|
||||
// https://github.com/mifi/lossless-cut/issues/975
|
||||
// But cover art / thumbnail streams don't count e.g. hevc with a png stream (disposition.attached_pic=1)
|
||||
return videoStreams.some(s => !['hevc', 'prores', 'mpeg4', 'tscc2'].includes(s.codec_name));
|
||||
}
|
||||
|
||||
export const isMasBuild = window.process.mas;
|
||||
export const isWindowsStoreBuild = window.process.windowsStore;
|
||||
export const isStoreBuild = isMasBuild || isWindowsStoreBuild;
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
// https://www.ffmpeg.org/doxygen/3.2/libavutil_2utils_8c_source.html#l00079
|
||||
export const defaultProcessedCodecTypes = [
|
||||
'video',
|
||||
'audio',
|
||||
'subtitle',
|
||||
'attachment',
|
||||
];
|
||||
|
||||
function getPerStreamQuirksFlags({ stream, outputIndex, outFormat }) {
|
||||
if (['mov', 'mp4'].includes(outFormat) && stream.codec_tag === '0x0000' && stream.codec_name === 'hevc') {
|
||||
return [`-tag:${outputIndex}`, 'hvc1'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function getMapStreamsArgs({ outFormat, allFilesMeta, copyFileStreams }) {
|
||||
let args = [];
|
||||
let outputIndex = 0;
|
||||
copyFileStreams.forEach(({ streamIds, path }, fileIndex) => {
|
||||
streamIds.forEach((streamId) => {
|
||||
const { streams } = allFilesMeta[path];
|
||||
const stream = streams.find((s) => s.index === streamId);
|
||||
args = [
|
||||
...args,
|
||||
'-map', `${fileIndex}:${streamId}`,
|
||||
...getPerStreamQuirksFlags({ stream, outputIndex, outFormat }),
|
||||
];
|
||||
outputIndex += 1;
|
||||
});
|
||||
});
|
||||
return args;
|
||||
}
|
||||
|
||||
export function shouldCopyStreamByDefault(stream) {
|
||||
if (!defaultProcessedCodecTypes.includes(stream.codec_type)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isStreamThumbnail(stream) {
|
||||
return stream && stream.disposition && stream.disposition.attached_pic === 1;
|
||||
}
|
||||
|
||||
export const getAudioStreams = (streams) => streams.filter(stream => stream.codec_type === 'audio');
|
||||
export const getRealVideoStreams = (streams) => streams.filter(stream => stream.codec_type === 'video' && !isStreamThumbnail(stream));
|
||||
export const getSubtitleStreams = (streams) => streams.filter(stream => stream.codec_type === 'subtitle');
|
||||
|
||||
export function getStreamIdsToCopy({ streams, includeAllStreams }) {
|
||||
if (includeAllStreams) return streams.map((stream) => stream.index);
|
||||
|
||||
// If preserveMetadataOnMerge option is enabled, we MUST explicitly map all streams even if includeAllStreams=false.
|
||||
// We cannot use the ffmpeg's automatic stream selection or else ffmpeg might use the metadata source input (index 1)
|
||||
// instead of the concat input (index 0)
|
||||
// https://ffmpeg.org/ffmpeg.html#Automatic-stream-selection
|
||||
const ret = [];
|
||||
// TODO try to mimic ffmpeg default mapping https://ffmpeg.org/ffmpeg.html#Automatic-stream-selection
|
||||
const videoStreams = getRealVideoStreams(streams);
|
||||
const audioStreams = getAudioStreams(streams);
|
||||
const subtitleStreams = getSubtitleStreams(streams);
|
||||
if (videoStreams.length > 0) ret.push(videoStreams[0].index);
|
||||
if (audioStreams.length > 0) ret.push(audioStreams[0].index);
|
||||
if (subtitleStreams.length > 0) ret.push(subtitleStreams[0].index);
|
||||
return ret;
|
||||
}
|
||||
|
||||
// With these codecs, the player will not give a playback error, but instead only play audio
|
||||
export function doesPlayerSupportFile(streams) {
|
||||
const realVideoStreams = getRealVideoStreams(streams);
|
||||
// Don't check audio formats, assume all is OK
|
||||
if (realVideoStreams.length === 0) return true;
|
||||
// If we have at least one video that is NOT of the unsupported formats, assume the player will be able to play it natively
|
||||
// https://github.com/mifi/lossless-cut/issues/595
|
||||
// https://github.com/mifi/lossless-cut/issues/975
|
||||
// But cover art / thumbnail streams don't count e.g. hevc with a png stream (disposition.attached_pic=1)
|
||||
return realVideoStreams.some(s => !['hevc', 'prores', 'mpeg4', 'tscc2'].includes(s.codec_name));
|
||||
}
|
||||
|
||||
export function isAudioDefinitelyNotSupported(streams) {
|
||||
const audioStreams = getAudioStreams(streams);
|
||||
if (audioStreams.length === 0) return false;
|
||||
// TODO this could be improved
|
||||
return audioStreams.every(stream => ['ac3'].includes(stream.codec_name));
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import { getMapStreamsArgs, getStreamIdsToCopy } from './streams';
|
||||
|
||||
const streams1 = [
|
||||
{ index: 0, codec_type: 'video', codec_tag: '0x0000', codec_name: 'mjpeg', disposition: { attached_pic: 1 } },
|
||||
{ index: 1, codec_type: 'audio', codec_tag: '0x6134706d', codec_name: 'aac' },
|
||||
{ index: 2, codec_type: 'video', codec_tag: '0x31637661', codec_name: 'h264' },
|
||||
{ index: 3, codec_type: 'video', codec_tag: '0x0000', codec_name: 'hevc' },
|
||||
{ index: 4, codec_type: 'audio', codec_tag: '0x6134706d', codec_name: 'aac' },
|
||||
{ index: 5, codec_type: 'attachment', codec_tag: '0x0000', codec_name: 'ttf' },
|
||||
{ index: 6, codec_type: 'data', codec_tag: '0x64636d74' },
|
||||
];
|
||||
|
||||
// Some files haven't got a valid video codec tag set, so change it to hvc1 (default by ffmpeg is hev1 which doesn't work in QuickTime)
|
||||
// https://github.com/mifi/lossless-cut/issues/1032
|
||||
// https://stackoverflow.com/questions/63468587/what-hevc-codec-tag-to-use-with-fmp4-hvc1-or-hev1
|
||||
// https://stackoverflow.com/questions/32152090/encode-h265-to-hvc1-codec
|
||||
test('getMapStreamsArgs, tag', () => {
|
||||
const path = '/path/file.mp4';
|
||||
const outFormat = 'mp4';
|
||||
|
||||
expect(getMapStreamsArgs({
|
||||
allFilesMeta: { [path]: { streams: streams1 } },
|
||||
copyFileStreams: [{ path, streamIds: streams1.map((stream) => stream.index) }],
|
||||
outFormat,
|
||||
})).toEqual(['-map', '0:0', '-map', '0:1', '-map', '0:2', '-map', '0:3', '-tag:3', 'hvc1', '-map', '0:4', '-map', '0:5', '-map', '0:6']);
|
||||
});
|
||||
|
||||
test('getStreamIdsToCopy, includeAllStreams false', () => {
|
||||
const streamIdsToCopy = getStreamIdsToCopy({ streams: streams1, includeAllStreams: false });
|
||||
expect(streamIdsToCopy).toEqual([2, 1]);
|
||||
});
|
Ładowanie…
Reference in New Issue