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 { controlsBackground } from './colors';
|
||||||
import { captureFrameFromTag, captureFrameFfmpeg } from './capture-frame';
|
import { captureFrameFromTag, captureFrameFfmpeg } from './capture-frame';
|
||||||
import {
|
import {
|
||||||
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
|
getStreamFps, isCuttingStart, isCuttingEnd,
|
||||||
readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails,
|
readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails,
|
||||||
extractStreams, runStartupCheck,
|
extractStreams, runStartupCheck,
|
||||||
isAudioDefinitelyNotSupported, isIphoneHevc, tryMapChaptersToEdl,
|
isIphoneHevc, tryMapChaptersToEdl,
|
||||||
getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack,
|
getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrack,
|
||||||
} from './ffmpeg';
|
} from './ffmpeg';
|
||||||
|
import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, defaultProcessedCodecTypes, isAudioDefinitelyNotSupported, doesPlayerSupportFile } from './util/streams';
|
||||||
import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore';
|
import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore';
|
||||||
import { formatYouTube, getTimeFromFrameNum as getTimeFromFrameNumRaw, getFrameCountRaw } from './edlFormats';
|
import { formatYouTube, getTimeFromFrameNum as getTimeFromFrameNumRaw, getFrameCountRaw } from './edlFormats';
|
||||||
import {
|
import {
|
||||||
getOutPath, toast, errorToast, handleError, setFileNameTitle, getOutDir, getFileDir, withBlur,
|
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,
|
isDurationValid, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate,
|
||||||
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
|
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
|
||||||
deleteFiles, isStreamThumbnail, getAudioStreams, getVideoStreams, isOutOfSpaceError, shuffleArray,
|
deleteFiles, isOutOfSpaceError, shuffleArray,
|
||||||
} from './util';
|
} from './util';
|
||||||
import { formatDuration } from './util/duration';
|
import { formatDuration } from './util/duration';
|
||||||
import { adjustRate } from './util/rate-calculator';
|
import { adjustRate } from './util/rate-calculator';
|
||||||
|
@ -109,18 +110,16 @@ const App = memo(() => {
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [playerTime, setPlayerTime] = useState();
|
const [playerTime, setPlayerTime] = useState();
|
||||||
const [duration, setDuration] = useState();
|
const [duration, setDuration] = useState();
|
||||||
const [fileFormatData, setFileFormatData] = useState();
|
|
||||||
const [chapters, setChapters] = useState();
|
|
||||||
const [rotation, setRotation] = useState(360);
|
const [rotation, setRotation] = useState(360);
|
||||||
const [cutProgress, setCutProgress] = useState();
|
const [cutProgress, setCutProgress] = useState();
|
||||||
const [startTimeOffset, setStartTimeOffset] = useState(0);
|
const [startTimeOffset, setStartTimeOffset] = useState(0);
|
||||||
const [filePath, setFilePath] = useState('');
|
const [filePath, setFilePath] = useState('');
|
||||||
const [externalStreamFiles, setExternalStreamFiles] = useState([]);
|
const [externalFilesMeta, setExternalFilesMeta] = useState({});
|
||||||
const [customTagsByFile, setCustomTagsByFile] = useState({});
|
const [customTagsByFile, setCustomTagsByFile] = useState({});
|
||||||
const [customTagsByStreamId, setCustomTagsByStreamId] = useState({});
|
const [customTagsByStreamId, setCustomTagsByStreamId] = useState({});
|
||||||
const [dispositionByStreamId, setDispositionByStreamId] = useState({});
|
const [dispositionByStreamId, setDispositionByStreamId] = useState({});
|
||||||
const [detectedFps, setDetectedFps] = useState();
|
const [detectedFps, setDetectedFps] = useState();
|
||||||
const [mainStreams, setMainStreams] = useState([]);
|
const [mainFileMeta, setMainFileMeta] = useState({ streams: [], formatData: {} });
|
||||||
const [mainVideoStream, setMainVideoStream] = useState();
|
const [mainVideoStream, setMainVideoStream] = useState();
|
||||||
const [mainAudioStream, setMainAudioStream] = useState();
|
const [mainAudioStream, setMainAudioStream] = useState();
|
||||||
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({});
|
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({});
|
||||||
|
@ -674,7 +673,7 @@ const App = memo(() => {
|
||||||
return { cancel: false, newCustomOutDir };
|
return { cancel: false, newCustomOutDir };
|
||||||
}, [customOutDir, setCustomOutDir]);
|
}, [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;
|
if (workingRef.current) return;
|
||||||
try {
|
try {
|
||||||
setConcatDialogVisible(false);
|
setConcatDialogVisible(false);
|
||||||
|
@ -695,7 +694,7 @@ const App = memo(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('merge', paths);
|
// 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!') });
|
openDirToast({ icon: 'success', dirPath: outDir, text: i18n.t('Files merged!') });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isOutOfSpaceError(err)) {
|
if (isOutOfSpaceError(err)) {
|
||||||
|
@ -743,6 +742,10 @@ const App = memo(() => {
|
||||||
!!(copyStreamIdsByFile[path] || {})[streamId]
|
!!(copyStreamIdsByFile[path] || {})[streamId]
|
||||||
), [copyStreamIdsByFile]);
|
), [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 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]);
|
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]) => ({
|
const copyFileStreams = useMemo(() => Object.entries(copyStreamIdsByFile).map(([path, streamIdsMap]) => ({
|
||||||
path,
|
path,
|
||||||
streamIds: Object.keys(streamIdsMap).filter(index => streamIdsMap[index]),
|
streamIds: Object.keys(streamIdsMap).filter(index => streamIdsMap[index]).map((streamIdStr) => parseInt(streamIdStr, 10)),
|
||||||
})), [copyStreamIdsByFile]);
|
})), [copyStreamIdsByFile]);
|
||||||
|
|
||||||
const numStreamsToCopy = copyFileStreams
|
const numStreamsToCopy = copyFileStreams
|
||||||
.reduce((acc, { streamIds }) => acc + streamIds.length, 0);
|
.reduce((acc, { streamIds }) => acc + streamIds.length, 0);
|
||||||
|
|
||||||
const numStreamsTotal = [
|
const allFilesMeta = useMemo(() => ({
|
||||||
...mainStreams,
|
...externalFilesMeta,
|
||||||
...flatMap(Object.values(externalStreamFiles), ({ streams }) => streams),
|
[filePath]: mainFileMeta,
|
||||||
].length;
|
}), [externalFilesMeta, filePath, mainFileMeta]);
|
||||||
|
|
||||||
|
const numStreamsTotal = flatMap(Object.values(allFilesMeta), ({ streams }) => streams).length;
|
||||||
|
|
||||||
const toggleStripAudio = useCallback(() => {
|
const toggleStripAudio = useCallback(() => {
|
||||||
setCopyStreamIdsForPath(filePath, (old) => {
|
setCopyStreamIdsForPath(filePath, (old) => {
|
||||||
|
@ -888,19 +893,17 @@ const App = memo(() => {
|
||||||
setCutStartTimeManual();
|
setCutStartTimeManual();
|
||||||
setCutEndTimeManual();
|
setCutEndTimeManual();
|
||||||
setFileFormat();
|
setFileFormat();
|
||||||
setFileFormatData();
|
|
||||||
setChapters();
|
|
||||||
setDetectedFileFormat();
|
setDetectedFileFormat();
|
||||||
setRotation(360);
|
setRotation(360);
|
||||||
setCutProgress();
|
setCutProgress();
|
||||||
setStartTimeOffset(0);
|
setStartTimeOffset(0);
|
||||||
setFilePath(''); // Setting video src="" prevents memory leak in chromium
|
setFilePath(''); // Setting video src="" prevents memory leak in chromium
|
||||||
setExternalStreamFiles([]);
|
setExternalFilesMeta({});
|
||||||
setCustomTagsByFile({});
|
setCustomTagsByFile({});
|
||||||
setCustomTagsByStreamId({});
|
setCustomTagsByStreamId({});
|
||||||
setDispositionByStreamId({});
|
setDispositionByStreamId({});
|
||||||
setDetectedFps();
|
setDetectedFps();
|
||||||
setMainStreams([]);
|
setMainFileMeta({ streams: [], formatData: [] });
|
||||||
setMainVideoStream();
|
setMainVideoStream();
|
||||||
setMainAudioStream();
|
setMainAudioStream();
|
||||||
setCopyStreamIdsByFile({});
|
setCopyStreamIdsByFile({});
|
||||||
|
@ -1153,17 +1156,17 @@ const App = memo(() => {
|
||||||
const state = {
|
const state = {
|
||||||
filePath,
|
filePath,
|
||||||
fileFormat,
|
fileFormat,
|
||||||
externalStreamFiles,
|
setExternalFilesMeta,
|
||||||
mainStreams,
|
mainStreams,
|
||||||
copyStreamIdsByFile,
|
copyStreamIdsByFile,
|
||||||
cutSegments: cutSegments.map(s => ({ start: s.start, end: s.end })),
|
cutSegments: cutSegments.map(s => ({ start: s.start, end: s.end })),
|
||||||
fileFormatData,
|
mainFileFormatData,
|
||||||
rotation,
|
rotation,
|
||||||
shortestFlag,
|
shortestFlag,
|
||||||
};
|
};
|
||||||
|
|
||||||
openSendReportDialog(err, state);
|
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 handleCutFailed = useCallback(async (err) => {
|
||||||
const sendErrorReport = await showCutFailedDialog({ detectedFileFormat });
|
const sendErrorReport = await showCutFailedDialog({ detectedFileFormat });
|
||||||
|
@ -1205,6 +1208,7 @@ const App = memo(() => {
|
||||||
videoDuration: duration,
|
videoDuration: duration,
|
||||||
rotation: isRotationSet ? effectiveRotation : undefined,
|
rotation: isRotationSet ? effectiveRotation : undefined,
|
||||||
copyFileStreams,
|
copyFileStreams,
|
||||||
|
allFilesMeta,
|
||||||
keyframeCut,
|
keyframeCut,
|
||||||
segments: segmentsToExport,
|
segments: segmentsToExport,
|
||||||
segmentsFileNames: outSegFileNames,
|
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.')];
|
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
|
// 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) {
|
if (exportExtraStreams) {
|
||||||
try {
|
try {
|
||||||
|
@ -1275,7 +1279,7 @@ const App = memo(() => {
|
||||||
setWorking();
|
setWorking();
|
||||||
setCutProgress();
|
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 () => {
|
const onExportPress = useCallback(async () => {
|
||||||
if (!filePath || workingRef.current) return;
|
if (!filePath || workingRef.current) return;
|
||||||
|
@ -1407,16 +1411,14 @@ const App = memo(() => {
|
||||||
|
|
||||||
const fileFormatNew = await getSmarterOutFormat(fp, fileMeta.format);
|
const fileFormatNew = await getSmarterOutFormat(fp, fileMeta.format);
|
||||||
|
|
||||||
const { streams } = fileMeta;
|
|
||||||
|
|
||||||
// console.log(streams, fileMeta.format, fileFormat);
|
// console.log(streams, fileMeta.format, fileFormat);
|
||||||
|
|
||||||
if (!fileFormatNew) throw new Error('Unable to determine file format');
|
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 videoStreams = getRealVideoStreams(fileMeta.streams);
|
||||||
const audioStreams = getAudioStreams(streams);
|
const audioStreams = getAudioStreams(fileMeta.streams);
|
||||||
|
|
||||||
const videoStream = videoStreams[0];
|
const videoStream = videoStreams[0];
|
||||||
const audioStream = audioStreams[0];
|
const audioStream = audioStreams[0];
|
||||||
|
@ -1426,22 +1428,14 @@ const App = memo(() => {
|
||||||
|
|
||||||
const detectedFpsNew = haveVideoStream ? getStreamFps(videoStream) : undefined;
|
const detectedFpsNew = haveVideoStream ? getStreamFps(videoStream) : undefined;
|
||||||
|
|
||||||
const shouldCopyStreamByDefault = (stream) => {
|
const copyStreamIdsForPathNew = fromPairs(fileMeta.streams.map((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) => [
|
|
||||||
stream.index, shouldCopyStreamByDefault(stream),
|
stream.index, shouldCopyStreamByDefault(stream),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
if (timecode) setStartTimeOffset(timecode);
|
if (timecode) setStartTimeOffset(timecode);
|
||||||
if (detectedFpsNew != null) setDetectedFps(detectedFpsNew);
|
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') });
|
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();
|
const hasLoadedExistingHtml5FriendlyFile = await checkAndSetExistingHtml5FriendlyFile();
|
||||||
|
|
||||||
// 'fastest' works with almost all video files
|
// '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);
|
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') });
|
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(() => {
|
batchedUpdates(() => {
|
||||||
setMainStreams(streams);
|
setMainFileMeta({ streams: fileMeta.streams, formatData: fileMeta.format, chapters: fileMeta.chapters });
|
||||||
setMainVideoStream(videoStream);
|
setMainVideoStream(videoStream);
|
||||||
setMainAudioStream(audioStream);
|
setMainAudioStream(audioStream);
|
||||||
setCopyStreamIdsForPath(fp, () => copyStreamIdsForPathNew);
|
setCopyStreamIdsForPath(fp, () => copyStreamIdsForPathNew);
|
||||||
setFileNameTitle(fp);
|
setFileNameTitle(fp);
|
||||||
setFileFormat(outFormatLocked || fileFormatNew);
|
setFileFormat(outFormatLocked || fileFormatNew);
|
||||||
setDetectedFileFormat(fileFormatNew);
|
setDetectedFileFormat(fileFormatNew);
|
||||||
setFileFormatData(fileMeta.format);
|
|
||||||
setChapters(fileMeta.chapters);
|
|
||||||
|
|
||||||
// This needs to be last, because it triggers <video> to load the video
|
// This needs to be last, because it triggers <video> to load the video
|
||||||
// If not, onVideoError might be triggered before setWorking() has been cleared.
|
// If not, onVideoError might be triggered before setWorking() has been cleared.
|
||||||
|
@ -1836,12 +1828,12 @@ const App = memo(() => {
|
||||||
}, [customOutDir, filePath, mainStreams, outputDir, setWorking]);
|
}, [customOutDir, filePath, mainStreams, outputDir, setWorking]);
|
||||||
|
|
||||||
const addStreamSourceFile = useCallback(async (path) => {
|
const addStreamSourceFile = useCallback(async (path) => {
|
||||||
if (externalStreamFiles[path]) return;
|
if (allFilesMeta[path]) return;
|
||||||
const { streams, format: formatData } = await readFileMeta(path);
|
const fileMeta = await readFileMeta(path);
|
||||||
// console.log('streams', streams);
|
// console.log('streams', fileMeta.streams);
|
||||||
setExternalStreamFiles(old => ({ ...old, [path]: { streams, formatData } }));
|
setExternalFilesMeta((old) => ({ ...old, [path]: { streams: fileMeta.streams, formatData: fileMeta.format, chapters: fileMeta.chapters } }));
|
||||||
setCopyStreamIdsForPath(path, () => fromPairs(streams.map(({ index }) => [index, true])));
|
setCopyStreamIdsForPath(path, () => fromPairs(fileMeta.streams.map(({ index }) => [index, true])));
|
||||||
}, [externalStreamFiles, setCopyStreamIdsForPath]);
|
}, [allFilesMeta, setCopyStreamIdsForPath]);
|
||||||
|
|
||||||
const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]);
|
const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]);
|
||||||
|
|
||||||
|
@ -2404,10 +2396,11 @@ const App = memo(() => {
|
||||||
>
|
>
|
||||||
<StreamsSelector
|
<StreamsSelector
|
||||||
mainFilePath={filePath}
|
mainFilePath={filePath}
|
||||||
mainFileFormatData={fileFormatData}
|
mainFileFormatData={mainFileFormatData}
|
||||||
mainFileChapters={chapters}
|
mainFileChapters={mainFileChapters}
|
||||||
externalFiles={externalStreamFiles}
|
allFilesMeta={allFilesMeta}
|
||||||
setExternalFiles={setExternalStreamFiles}
|
externalFilesMeta={externalFilesMeta}
|
||||||
|
setExternalFilesMeta={setExternalFilesMeta}
|
||||||
showAddStreamSourceDialog={showAddStreamSourceDialog}
|
showAddStreamSourceDialog={showAddStreamSourceDialog}
|
||||||
streams={mainStreams}
|
streams={mainStreams}
|
||||||
isCopyingStreamId={isCopyingStreamId}
|
isCopyingStreamId={isCopyingStreamId}
|
||||||
|
|
|
@ -100,8 +100,8 @@ const TagEditor = memo(({ existingTags, customTags, onTagChange, onTagReset }) =
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const EditFileDialog = memo(({ editingFile, externalFiles, mainFileFormatData, mainFilePath, customTagsByFile, setCustomTagsByFile }) => {
|
const EditFileDialog = memo(({ editingFile, allFilesMeta, customTagsByFile, setCustomTagsByFile }) => {
|
||||||
const formatData = editingFile === mainFilePath ? mainFileFormatData : externalFiles[editingFile].formatData;
|
const { formatData } = allFilesMeta[editingFile];
|
||||||
const existingTags = formatData.tags || {};
|
const existingTags = formatData.tags || {};
|
||||||
const customTags = customTagsByFile[editingFile] || {};
|
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} />;
|
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 EditStreamDialog = memo(({ editingStream: { streamId: editingStreamId, path: editingFile }, allFilesMeta, customTagsByStreamId, setCustomTagsByStreamId, dispositionByStreamId, setDispositionByStreamId }) => {
|
||||||
const streams = editingFile === mainFilePath ? mainFileStreams : externalFiles[editingFile].streams;
|
const { streams } = allFilesMeta[editingFile];
|
||||||
const stream = useMemo(() => streams.find((s) => s.index === editingStreamId), [streams, editingStreamId]);
|
const stream = useMemo(() => streams.find((s) => s.index === editingStreamId), [streams, editingStreamId]);
|
||||||
|
|
||||||
const existingTags = useMemo(() => (stream && stream.tags) || {}, [stream]);
|
const existingTags = useMemo(() => (stream && stream.tags) || {}, [stream]);
|
||||||
|
@ -325,7 +325,7 @@ const fileStyle = { marginBottom: 20, padding: 5, minWidth: '100%', overflowX: '
|
||||||
|
|
||||||
const StreamsSelector = memo(({
|
const StreamsSelector = memo(({
|
||||||
mainFilePath, mainFileFormatData, streams: mainFileStreams, mainFileChapters, isCopyingStreamId, toggleCopyStreamId,
|
mainFilePath, mainFileFormatData, streams: mainFileStreams, mainFileChapters, isCopyingStreamId, toggleCopyStreamId,
|
||||||
setCopyStreamIdsForPath, onExtractStreamPress, onExtractAllStreamsPress, externalFiles, setExternalFiles,
|
setCopyStreamIdsForPath, onExtractStreamPress, onExtractAllStreamsPress, allFilesMeta, externalFilesMeta, setExternalFilesMeta,
|
||||||
showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams,
|
showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams,
|
||||||
AutoExportToggler, customTagsByFile, setCustomTagsByFile, customTagsByStreamId, setCustomTagsByStreamId,
|
AutoExportToggler, customTagsByFile, setCustomTagsByFile, customTagsByStreamId, setCustomTagsByStreamId,
|
||||||
dispositionByStreamId, setDispositionByStreamId,
|
dispositionByStreamId, setDispositionByStreamId,
|
||||||
|
@ -345,7 +345,7 @@ const StreamsSelector = memo(({
|
||||||
|
|
||||||
async function removeFile(path) {
|
async function removeFile(path) {
|
||||||
setCopyStreamIdsForPath(path, () => ({}));
|
setCopyStreamIdsForPath(path, () => ({}));
|
||||||
setExternalFiles((old) => {
|
setExternalFilesMeta((old) => {
|
||||||
const { [path]: val, ...rest } = old;
|
const { [path]: val, ...rest } = old;
|
||||||
return rest;
|
return rest;
|
||||||
});
|
});
|
||||||
|
@ -365,7 +365,7 @@ const StreamsSelector = memo(({
|
||||||
setCopyStreamIdsForPath(path, (old) => Object.fromEntries(Object.entries(old).map(([streamId]) => [streamId, enabled])));
|
setCopyStreamIdsForPath(path, (old) => Object.fromEntries(Object.entries(old).map(([streamId]) => [streamId, enabled])));
|
||||||
}
|
}
|
||||||
|
|
||||||
const externalFilesEntries = Object.entries(externalFiles);
|
const externalFilesEntries = Object.entries(externalFilesMeta);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -452,7 +452,7 @@ const StreamsSelector = memo(({
|
||||||
confirmLabel={t('Done')}
|
confirmLabel={t('Done')}
|
||||||
onCloseComplete={() => setEditingFile()}
|
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>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
|
@ -462,7 +462,7 @@ const StreamsSelector = memo(({
|
||||||
confirmLabel={t('Done')}
|
confirmLabel={t('Done')}
|
||||||
onCloseComplete={() => setEditingStream()}
|
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>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -45,6 +45,7 @@ const ConcatDialog = memo(({
|
||||||
const [paths, setPaths] = useState(initialPaths);
|
const [paths, setPaths] = useState(initialPaths);
|
||||||
const [includeAllStreams, setIncludeAllStreams] = useState(false);
|
const [includeAllStreams, setIncludeAllStreams] = useState(false);
|
||||||
const [sortDesc, setSortDesc] = useState();
|
const [sortDesc, setSortDesc] = useState();
|
||||||
|
const [fileMeta, setFileMeta] = useState();
|
||||||
|
|
||||||
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
||||||
|
|
||||||
|
@ -56,11 +57,13 @@ const ConcatDialog = memo(({
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const firstPath = initialPaths[0];
|
const firstPath = initialPaths[0];
|
||||||
|
setFileMeta();
|
||||||
setFileFormat();
|
setFileFormat();
|
||||||
setDetectedFileFormat();
|
setDetectedFileFormat();
|
||||||
const fileMeta = await readFileMeta(firstPath);
|
const fileMetaNew = await readFileMeta(firstPath);
|
||||||
const fileFormatNew = await getSmarterOutFormat(firstPath, fileMeta.format);
|
const fileFormatNew = await getSmarterOutFormat(firstPath, fileMetaNew.format);
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
|
setFileMeta(fileMetaNew);
|
||||||
setFileFormat(fileFormatNew);
|
setFileFormat(fileFormatNew);
|
||||||
setDetectedFileFormat(fileFormatNew);
|
setDetectedFileFormat(fileFormatNew);
|
||||||
})().catch(console.error);
|
})().catch(console.error);
|
||||||
|
@ -86,6 +89,8 @@ const ConcatDialog = memo(({
|
||||||
|
|
||||||
const onOutputFormatUserChange = useCallback((newFormat) => setFileFormat(newFormat), [setFileFormat]);
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={t('Merge/concatenate files')}
|
title={t('Merge/concatenate files')}
|
||||||
|
@ -98,7 +103,7 @@ const ConcatDialog = memo(({
|
||||||
{fileFormat && detectedFileFormat && <OutputFormatSelect style={{ maxWidth: 150 }} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />}
|
{fileFormat && detectedFileFormat && <OutputFormatSelect style={{ maxWidth: 150 }} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />}
|
||||||
<Button iconBefore={sortDesc ? SortAlphabeticalDescIcon : SortAlphabeticalIcon} onClick={onSortClick}>{t('Sort items')}</Button>
|
<Button iconBefore={sortDesc ? SortAlphabeticalDescIcon : SortAlphabeticalIcon} onClick={onSortClick}>{t('Sort items')}</Button>
|
||||||
<Button onClick={onHide} style={{ marginLeft: 10 }}>Cancel</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 i18n from 'i18next';
|
||||||
import Timecode from 'smpte-timecode';
|
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 execa = window.require('execa');
|
||||||
const { join } = window.require('path');
|
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',
|
'-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 };
|
return { format, streams, chapters };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Windows will throw error with code ENOENT if format detection fails.
|
// 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 });
|
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 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) {
|
export function isIphoneHevc(format, streams) {
|
||||||
if (!streams.some((s) => s.codec_name === 'hevc')) return false;
|
if (!streams.some((s) => s.codec_name === 'hevc')) return false;
|
||||||
const makeTag = format.tags && format.tags['com.apple.quicktime.make'];
|
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 pMap from 'p-map';
|
||||||
|
|
||||||
import { getOutPath, transferTimestamps, getOutFileExtension, getOutDir, isMac, deleteDispositionValue, getHtml5ifiedPath } from '../util';
|
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 execa = window.require('execa');
|
||||||
const { join, resolve } = window.require('path');
|
const { join, resolve } = window.require('path');
|
||||||
|
@ -52,6 +53,7 @@ function getMatroskaFlags() {
|
||||||
|
|
||||||
const getChaptersInputArgs = (ffmetadataPath) => (ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []);
|
const getChaptersInputArgs = (ffmetadataPath) => (ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []);
|
||||||
|
|
||||||
|
|
||||||
function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
||||||
const optionalTransferTimestamps = useCallback(async (...args) => {
|
const optionalTransferTimestamps = useCallback(async (...args) => {
|
||||||
if (enableTransferTimestamps) await transferTimestamps(...args);
|
if (enableTransferTimestamps) await transferTimestamps(...args);
|
||||||
|
@ -61,7 +63,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
||||||
|
|
||||||
const cutMultiple = useCallback(async ({
|
const cutMultiple = useCallback(async ({
|
||||||
outputDir, segments, segmentsFileNames, videoDuration, rotation,
|
outputDir, segments, segmentsFileNames, videoDuration, rotation,
|
||||||
onProgress: onTotalProgress, keyframeCut, copyFileStreams, outFormat,
|
onProgress: onTotalProgress, keyframeCut, copyFileStreams, allFilesMeta, outFormat,
|
||||||
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs,
|
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs,
|
||||||
customTagsByFile, customTagsByStreamId, dispositionByStreamId, chapters,
|
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
|
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
|
// 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
|
if (copiedStreamIndex === -1) return undefined; // Could happen if a tag has been edited on a stream, but the stream is disabled
|
||||||
return streamCount + copiedStreamIndex;
|
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 } } }
|
// Example: { 'file.mp4': { 0: { attached_pic: 1 } } }
|
||||||
const customDispositionArgs = lessDeepMap(dispositionByStreamId, (path, streamId, disposition) => {
|
const customDispositionArgs = lessDeepMap(dispositionByStreamId, (path, streamId, disposition) => {
|
||||||
if (disposition == null) return [];
|
if (disposition == null) return [];
|
||||||
|
@ -166,7 +170,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
||||||
|
|
||||||
...(shortestFlag ? ['-shortest'] : []),
|
...(shortestFlag ? ['-shortest'] : []),
|
||||||
|
|
||||||
...flatMapDeep(copyFileStreamsFiltered, ({ streamIds }, fileIndex) => streamIds.map(streamId => ['-map', `${fileIndex}:${streamId}`])),
|
...mapStreamsArgs,
|
||||||
|
|
||||||
'-map_metadata', '0',
|
'-map_metadata', '0',
|
||||||
|
|
||||||
|
@ -236,9 +240,11 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
||||||
}
|
}
|
||||||
}, [filePath, optionalTransferTimestamps]);
|
}, [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);
|
console.log('Merging files', { paths }, 'to', outPath);
|
||||||
|
|
||||||
|
const firstPath = paths[0];
|
||||||
|
|
||||||
const durations = await pMap(paths, getDuration, { concurrency: 1 });
|
const durations = await pMap(paths, getDuration, { concurrency: 1 });
|
||||||
const totalDuration = sum(durations);
|
const totalDuration = sum(durations);
|
||||||
|
|
||||||
|
@ -267,7 +273,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
||||||
let metadataSourceIndex;
|
let metadataSourceIndex;
|
||||||
if (preserveMetadataOnMerge) {
|
if (preserveMetadataOnMerge) {
|
||||||
// If preserve metadata, add the first file (we will get metadata from this input)
|
// 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;
|
let chaptersInputIndex;
|
||||||
|
@ -276,14 +282,12 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
||||||
chaptersInputIndex = addInput(getChaptersInputArgs(chaptersPath));
|
chaptersInputIndex = addInput(getChaptersInputArgs(chaptersPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
let map;
|
const streamIdsToCopy = getStreamIdsToCopy({ streams, includeAllStreams });
|
||||||
if (includeAllStreams) map = ['-map', '0'];
|
const mapStreamsArgs = getMapStreamsArgs({
|
||||||
// If preserveMetadataOnMerge option is enabled, we need to explicitly map even if allStreams=false.
|
allFilesMeta: { [firstPath]: { streams } },
|
||||||
// We cannot use the ffmpeg's automatic stream selection or else ffmpeg might use the metadata source input (index 1)
|
copyFileStreams: [{ path: firstPath, streamIds: streamIdsToCopy }],
|
||||||
// instead of the concat input (index 0)
|
outFormat,
|
||||||
// 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
|
|
||||||
|
|
||||||
// Keep this similar to cutSingle()
|
// Keep this similar to cutSingle()
|
||||||
const ffmpegArgs = [
|
const ffmpegArgs = [
|
||||||
|
@ -295,7 +299,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
||||||
|
|
||||||
'-c', 'copy',
|
'-c', 'copy',
|
||||||
|
|
||||||
...map,
|
...mapStreamsArgs,
|
||||||
|
|
||||||
// -map_metadata 0 with concat demuxer doesn't transfer metadata from the concat'ed file input (index 0) when merging.
|
// -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
|
// 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));
|
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]);
|
}, [optionalTransferTimestamps]);
|
||||||
|
|
||||||
const autoConcatCutSegments = useCallback(async ({ customOutDir, isCustomFormatSelected, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge }) => {
|
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 });
|
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 });
|
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => fs.unlink(path), { concurrency: 5 });
|
||||||
}, [concatFiles, filePath]);
|
}, [concatFiles, filePath]);
|
||||||
|
|
||||||
|
|
19
src/util.js
19
src/util.js
|
@ -146,25 +146,6 @@ export function dragPreventer(ev) {
|
||||||
ev.preventDefault();
|
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 isMasBuild = window.process.mas;
|
||||||
export const isWindowsStoreBuild = window.process.windowsStore;
|
export const isWindowsStoreBuild = window.process.windowsStore;
|
||||||
export const isStoreBuild = isMasBuild || isWindowsStoreBuild;
|
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