1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 18:32:34 +01:00

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 #1032
This commit is contained in:
Mikael Finstad 2022-02-24 12:27:50 +08:00
parent bd50d25b85
commit 01c9ebd1c7
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
8 changed files with 202 additions and 117 deletions

View File

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

View File

@ -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>
</>
);

View File

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

View File

@ -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'];

View File

@ -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]);

View File

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

83
src/util/streams.js Normal file
View File

@ -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));
}

31
src/util/streams.test.js Normal file
View File

@ -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]);
});