diff --git a/src/StreamsSelector.jsx b/src/StreamsSelector.jsx index 3a55d728..740d13aa 100644 --- a/src/StreamsSelector.jsx +++ b/src/StreamsSelector.jsx @@ -4,6 +4,7 @@ import { FaVideo, FaVideoSlash, FaFileExport, FaFileImport, FaVolumeUp, FaVolume import { GoFileBinary } from 'react-icons/go'; import { MdSubtitles } from 'react-icons/md'; import Swal from 'sweetalert2'; +import { SegmentedControl } from 'evergreen-ui'; import withReactContent from 'sweetalert2-react-content'; @@ -12,10 +13,18 @@ const ReactSwal = withReactContent(Swal); const { formatDuration } = require('./util'); const { getStreamFps } = require('./ffmpeg'); +function onInfoClick(s, title) { + ReactSwal.fire({ + showCloseButton: true, + title, + html:
{JSON.stringify(s, null, 2)}
, + }); +} -const Stream = memo(({ stream, onToggle, copyStream }) => { +const Stream = memo(({ stream, onToggle, copyStream, fileDuration }) => { const bitrate = parseInt(stream.bit_rate, 10); - const duration = parseInt(stream.duration, 10); + const streamDuration = parseInt(stream.duration, 10); + const duration = !Number.isNaN(streamDuration) ? streamDuration : fileDuration; let Icon; if (stream.codec_type === 'audio') { @@ -30,15 +39,6 @@ const Stream = memo(({ stream, onToggle, copyStream }) => { const streamFps = getStreamFps(stream); - function onInfoClick(s) { - ReactSwal.fire({ - showCloseButton: true, - icon: 'info', - title: 'Stream info', - html:
{JSON.stringify(s, null, 2)}
, - }); - } - const onClick = () => onToggle && onToggle(stream.index); return ( @@ -52,18 +52,34 @@ const Stream = memo(({ stream, onToggle, copyStream }) => { {stream.nb_frames} {!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`} {stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(2)}fps`} - onInfoClick(stream)} size={26} /> + onInfoClick(stream, 'Stream info')} size={26} /> ); }); +function renderFileRow(path, formatData, onTrashClick) { + return ( + + {onTrashClick && } + {path.replace(/.*\/([^/]+)$/, '$1')} + onInfoClick(formatData, 'File info')} size={26} /> + + ); +} + const StreamsSelector = memo(({ - mainFilePath, streams: existingStreams, isCopyingStreamId, toggleCopyStreamId, + mainFilePath, mainFileFormatData, streams: existingStreams, isCopyingStreamId, toggleCopyStreamId, setCopyStreamIdsForPath, onExtractAllStreamsPress, externalFiles, setExternalFiles, - showAddStreamSourceDialog, + showAddStreamSourceDialog, shortestFlag, setShortestFlag, exportExtraStreams, }) => { if (!existingStreams) return null; + function getFormatDuration(formatData) { + if (!formatData || !formatData.duration) return undefined; + const parsed = parseFloat(formatData.duration, 10); + if (Number.isNaN(parsed)) return undefined; + return parsed; + } async function removeFile(path) { setCopyStreamIdsForPath(path, () => ({})); @@ -73,6 +89,8 @@ const StreamsSelector = memo(({ }); } + const externalFilesEntries = Object.entries(externalFiles); + return (

Click to select which tracks to keep when exporting:

@@ -94,23 +112,23 @@ const StreamsSelector = memo(({ + {renderFileRow(mainFilePath, mainFileFormatData)} + {existingStreams.map((stream) => ( toggleCopyStreamId(mainFilePath, streamId)} + fileDuration={getFormatDuration(mainFileFormatData)} /> ))} - {Object.entries(externalFiles).map(([path, { streams }]) => ( + {externalFilesEntries.map(([path, { streams, formatData }]) => ( - - removeFile(path)} /> - - {path} - - + + + {renderFileRow(path, formatData, () => removeFile(path))} {streams.map((stream) => ( toggleCopyStreamId(path, streamId)} + fileDuration={getFormatDuration(formatData)} /> ))} @@ -125,11 +144,27 @@ const StreamsSelector = memo(({ + {externalFilesEntries.length > 0 && ( +
+
+ If the streams have different length, do you want to make the combined output file as long as the longest stream or the shortest stream? +
+ setShortestFlag(value === 'shortest')} + /> + +
+ )} + + {exportExtraStreams &&

Unprocessable tracks will be extracted to separate files. This can be configured in settings.

} +
- Include tracks from other file + Include more tracks from other file
- {Object.keys(externalFiles).length === 0 && ( + {externalFilesEntries.length === 0 && (
Export each track as individual files
diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 303e3c62..0065d5d0 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -145,7 +145,7 @@ function getNextPrevKeyframe(frames, cutTime, nextMode) { async function cut({ filePath, outFormat, cutFrom, cutTo, videoDuration, rotation, - onProgress, copyStreamIds, keyframeCut, outPath, appendFfmpegCommandLog, + onProgress, copyStreamIds, keyframeCut, outPath, appendFfmpegCommandLog, shortestFlag, }) { console.log('Cutting from', cutFrom, 'to', cutTo); @@ -178,6 +178,8 @@ async function cut({ '-c', 'copy', + ...(shortestFlag ? ['-shortest'] : []), + ...flatMapDeep(copyStreamIdsFiltered, ({ streamIds }, fileIndex) => streamIds.map(streamId => ['-map', `${fileIndex}:${streamId}`])), '-map_metadata', '0', // https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata @@ -210,7 +212,7 @@ async function cut({ async function cutMultiple({ customOutDir, filePath, segments: segmentsUnsorted, videoDuration, rotation, onProgress, keyframeCut, copyStreamIds, outFormat, isOutFormatUserSelected, - appendFfmpegCommandLog, + appendFfmpegCommandLog, shortestFlag, }) { const segments = sortBy(segmentsUnsorted, 'cutFrom'); const singleProgresses = {}; @@ -241,6 +243,7 @@ async function cutMultiple({ keyframeCut, cutFrom, cutTo, + shortestFlag, // eslint-disable-next-line no-loop-func onProgress: progress => onSingleProgress(i, progress), appendFfmpegCommandLog, @@ -372,13 +375,17 @@ function determineOutputFormat(ffprobeFormats, ft) { return ffprobeFormats[0] || undefined; } -async function getFormat(filePath) { - console.log('getFormat', filePath); +async function getFormatData(filePath) { + console.log('getFormatData', filePath); const { stdout } = await runFfprobe([ '-of', 'json', '-show_format', '-i', filePath, ]); - const formatsStr = JSON.parse(stdout).format.format_name; + return JSON.parse(stdout).format; +} + +async function getDefaultOutFormat(filePath, formatData) { + const formatsStr = formatData.format_name; console.log('formats', formatsStr); const formats = (formatsStr || '').split(','); @@ -504,7 +511,8 @@ function getStreamFps(stream) { module.exports = { cutMultiple, - getFormat, + getFormatData, + getDefaultOutFormat, html5ify, html5ifyDummy, mergeAnyFiles, diff --git a/src/renderer.jsx b/src/renderer.jsx index 488c443e..63e159c8 100644 --- a/src/renderer.jsx +++ b/src/renderer.jsx @@ -47,7 +47,10 @@ const ffmpeg = require('./ffmpeg'); const configStore = require('./store'); const edlStore = require('./edlStore'); -const { defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd } = ffmpeg; +const { + defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd, + getDefaultOutFormat, getFormatData, +} = ffmpeg; const { @@ -99,6 +102,7 @@ const App = memo(() => { const [playerTime, setPlayerTime] = useState(); const [duration, setDuration] = useState(); const [fileFormat, setFileFormat] = useState(); + const [fileFormatData, setFileFormatData] = useState(); const [detectedFileFormat, setDetectedFileFormat] = useState(); const [rotation, setRotation] = useState(360); const [cutProgress, setCutProgress] = useState(); @@ -116,6 +120,7 @@ const App = memo(() => { const [debouncedCommandedTime, setDebouncedCommandedTime] = useState(0); const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]); const [neighbouringFrames, setNeighbouringFrames] = useState([]); + const [shortestFlag, setShortestFlag] = useState(false); // Segment related state const [currentSegIndex, setCurrentSegIndex] = useState(0); @@ -229,6 +234,7 @@ const App = memo(() => { setCutStartTimeManual(); setCutEndTimeManual(); setFileFormat(); + setFileFormatData(); setDetectedFileFormat(); setRotation(360); setCutProgress(); @@ -242,6 +248,7 @@ const App = memo(() => { setStreamsSelectorShown(false); setZoom(1); setNeighbouringFrames([]); + setShortestFlag(false); }, [cutSegmentsHistory, setCutSegments, cancelCommandedTimeDebounce]); useEffect(() => () => { @@ -758,6 +765,7 @@ const App = memo(() => { segments: ffmpegSegments, onProgress: setCutProgress, appendFfmpegCommandLog, + shortestFlag, }); if (outFiles.length > 1 && autoMerge) { @@ -798,7 +806,7 @@ const App = memo(() => { effectiveRotation, outSegments, working, duration, filePath, keyframeCut, detectedFileFormat, autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyStreamIds, numStreamsToCopy, - exportExtraStreams, nonCopiedExtraStreams, outputDir, + exportExtraStreams, nonCopiedExtraStreams, outputDir, shortestFlag, ]); // TODO use ffmpeg to capture frame @@ -871,7 +879,9 @@ const App = memo(() => { setWorking(true); try { - const ff = await ffmpeg.getFormat(fp); + const fd = await getFormatData(fp); + + const ff = await getDefaultOutFormat(fp, fd); if (!ff) { errorToast('Unsupported file'); return; @@ -895,6 +905,7 @@ const App = memo(() => { setFilePath(fp); setFileFormat(ff); setDetectedFileFormat(ff); + setFileFormatData(fd); if (html5FriendlyPathRequested) { setHtml5FriendlyPath(html5FriendlyPathRequested); @@ -1003,8 +1014,9 @@ const App = memo(() => { const addStreamSourceFile = useCallback(async (path) => { if (externalStreamFiles[path]) return; const { streams } = await ffmpeg.getAllStreams(path); + const formatData = await getFormatData(path); // console.log('streams', streams); - setExternalStreamFiles(old => ({ ...old, [path]: { streams } })); + setExternalStreamFiles(old => ({ ...old, [path]: { streams, formatData } })); setCopyStreamIdsForPath(path, () => fromPairs(streams.map(({ index }) => [index, true]))); }, [externalStreamFiles]); @@ -1546,6 +1558,7 @@ const App = memo(() => { > { toggleCopyStreamId={toggleCopyStreamId} setCopyStreamIdsForPath={setCopyStreamIdsForPath} onExtractAllStreamsPress={onExtractAllStreamsPress} + shortestFlag={shortestFlag} + setShortestFlag={setShortestFlag} + exportExtraStreams={exportExtraStreams} />