improvements:

- add -shortest flag option
- show file duration when stream duration not present
- show file format info
pull/276/head
Mikael Finstad 6 years ago
parent 2cb171eaba
commit 3004cfee69

@ -4,6 +4,7 @@ import { FaVideo, FaVideoSlash, FaFileExport, FaFileImport, FaVolumeUp, FaVolume
import { GoFileBinary } from 'react-icons/go'; import { GoFileBinary } from 'react-icons/go';
import { MdSubtitles } from 'react-icons/md'; import { MdSubtitles } from 'react-icons/md';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import { SegmentedControl } from 'evergreen-ui';
import withReactContent from 'sweetalert2-react-content'; import withReactContent from 'sweetalert2-react-content';
@ -12,10 +13,18 @@ const ReactSwal = withReactContent(Swal);
const { formatDuration } = require('./util'); const { formatDuration } = require('./util');
const { getStreamFps } = require('./ffmpeg'); const { getStreamFps } = require('./ffmpeg');
function onInfoClick(s, title) {
ReactSwal.fire({
showCloseButton: true,
title,
html: <div style={{ whiteSpace: 'pre', textAlign: 'left', overflow: 'auto', maxHeight: 300, overflowY: 'scroll' }}>{JSON.stringify(s, null, 2)}</div>,
});
}
const Stream = memo(({ stream, onToggle, copyStream }) => { const Stream = memo(({ stream, onToggle, copyStream, fileDuration }) => {
const bitrate = parseInt(stream.bit_rate, 10); 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; let Icon;
if (stream.codec_type === 'audio') { if (stream.codec_type === 'audio') {
@ -30,15 +39,6 @@ const Stream = memo(({ stream, onToggle, copyStream }) => {
const streamFps = getStreamFps(stream); const streamFps = getStreamFps(stream);
function onInfoClick(s) {
ReactSwal.fire({
showCloseButton: true,
icon: 'info',
title: 'Stream info',
html: <div style={{ whiteSpace: 'pre', textAlign: 'left', overflow: 'auto', maxHeight: 300, overflowY: 'scroll' }}>{JSON.stringify(s, null, 2)}</div>,
});
}
const onClick = () => onToggle && onToggle(stream.index); const onClick = () => onToggle && onToggle(stream.index);
return ( return (
@ -52,18 +52,34 @@ const Stream = memo(({ stream, onToggle, copyStream }) => {
<td>{stream.nb_frames}</td> <td>{stream.nb_frames}</td>
<td>{!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`}</td> <td>{!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`}</td>
<td>{stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(2)}fps`}</td> <td>{stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(2)}fps`}</td>
<td><FaInfoCircle role="button" onClick={() => onInfoClick(stream)} size={26} /></td> <td><FaInfoCircle role="button" onClick={() => onInfoClick(stream, 'Stream info')} size={26} /></td>
</tr> </tr>
); );
}); });
function renderFileRow(path, formatData, onTrashClick) {
return (
<tr>
<td>{onTrashClick && <FaTrashAlt size={20} role="button" style={{ padding: '0 5px', cursor: 'pointer' }} onClick={onTrashClick} />}</td>
<td colSpan={8} title={path} style={{ wordBreak: 'break-all', fontWeight: 'bold' }}>{path.replace(/.*\/([^/]+)$/, '$1')}</td>
<td><FaInfoCircle role="button" onClick={() => onInfoClick(formatData, 'File info')} size={26} /></td>
</tr>
);
}
const StreamsSelector = memo(({ const StreamsSelector = memo(({
mainFilePath, streams: existingStreams, isCopyingStreamId, toggleCopyStreamId, mainFilePath, mainFileFormatData, streams: existingStreams, isCopyingStreamId, toggleCopyStreamId,
setCopyStreamIdsForPath, onExtractAllStreamsPress, externalFiles, setExternalFiles, setCopyStreamIdsForPath, onExtractAllStreamsPress, externalFiles, setExternalFiles,
showAddStreamSourceDialog, showAddStreamSourceDialog, shortestFlag, setShortestFlag, exportExtraStreams,
}) => { }) => {
if (!existingStreams) return null; 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) { async function removeFile(path) {
setCopyStreamIdsForPath(path, () => ({})); setCopyStreamIdsForPath(path, () => ({}));
@ -73,6 +89,8 @@ const StreamsSelector = memo(({
}); });
} }
const externalFilesEntries = Object.entries(externalFiles);
return ( return (
<div style={{ color: 'black', padding: 10 }}> <div style={{ color: 'black', padding: 10 }}>
<p>Click to select which tracks to keep when exporting:</p> <p>Click to select which tracks to keep when exporting:</p>
@ -94,23 +112,23 @@ const StreamsSelector = memo(({
</thead> </thead>
<tbody> <tbody>
{renderFileRow(mainFilePath, mainFileFormatData)}
{existingStreams.map((stream) => ( {existingStreams.map((stream) => (
<Stream <Stream
key={stream.index} key={stream.index}
stream={stream} stream={stream}
copyStream={isCopyingStreamId(mainFilePath, stream.index)} copyStream={isCopyingStreamId(mainFilePath, stream.index)}
onToggle={(streamId) => toggleCopyStreamId(mainFilePath, streamId)} onToggle={(streamId) => toggleCopyStreamId(mainFilePath, streamId)}
fileDuration={getFormatDuration(mainFileFormatData)}
/> />
))} ))}
{Object.entries(externalFiles).map(([path, { streams }]) => ( {externalFilesEntries.map(([path, { streams, formatData }]) => (
<Fragment key={path}> <Fragment key={path}>
<tr> <tr><td colSpan={10} /></tr>
<td><FaTrashAlt size={20} role="button" style={{ padding: '0 5px', cursor: 'pointer' }} onClick={() => removeFile(path)} /></td>
<td colSpan={9} style={{ paddingTop: 15 }}> {renderFileRow(path, formatData, () => removeFile(path))}
{path}
</td>
</tr>
{streams.map((stream) => ( {streams.map((stream) => (
<Stream <Stream
@ -118,6 +136,7 @@ const StreamsSelector = memo(({
stream={stream} stream={stream}
copyStream={isCopyingStreamId(path, stream.index)} copyStream={isCopyingStreamId(path, stream.index)}
onToggle={(streamId) => toggleCopyStreamId(path, streamId)} onToggle={(streamId) => toggleCopyStreamId(path, streamId)}
fileDuration={getFormatDuration(formatData)}
/> />
))} ))}
</Fragment> </Fragment>
@ -125,11 +144,27 @@ const StreamsSelector = memo(({
</tbody> </tbody>
</table> </table>
{externalFilesEntries.length > 0 && (
<div>
<div>
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?
</div>
<SegmentedControl
options={[{ label: 'Longest', value: 'longest' }, { label: 'Shortest', value: 'shortest' }]}
value={shortestFlag ? 'shortest' : 'longest'}
onChange={value => setShortestFlag(value === 'shortest')}
/>
</div>
)}
{exportExtraStreams && <p>Unprocessable tracks will be extracted to separate files. This can be configured in settings.</p>}
<div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={showAddStreamSourceDialog}> <div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={showAddStreamSourceDialog}>
<FaFileImport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> Include tracks from other file <FaFileImport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> Include more tracks from other file
</div> </div>
{Object.keys(externalFiles).length === 0 && ( {externalFilesEntries.length === 0 && (
<div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={onExtractAllStreamsPress}> <div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={onExtractAllStreamsPress}>
<FaFileExport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> Export each track as individual files <FaFileExport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> Export each track as individual files
</div> </div>

@ -145,7 +145,7 @@ function getNextPrevKeyframe(frames, cutTime, nextMode) {
async function cut({ async function cut({
filePath, outFormat, cutFrom, cutTo, videoDuration, rotation, filePath, outFormat, cutFrom, cutTo, videoDuration, rotation,
onProgress, copyStreamIds, keyframeCut, outPath, appendFfmpegCommandLog, onProgress, copyStreamIds, keyframeCut, outPath, appendFfmpegCommandLog, shortestFlag,
}) { }) {
console.log('Cutting from', cutFrom, 'to', cutTo); console.log('Cutting from', cutFrom, 'to', cutTo);
@ -178,6 +178,8 @@ async function cut({
'-c', 'copy', '-c', 'copy',
...(shortestFlag ? ['-shortest'] : []),
...flatMapDeep(copyStreamIdsFiltered, ({ streamIds }, fileIndex) => streamIds.map(streamId => ['-map', `${fileIndex}:${streamId}`])), ...flatMapDeep(copyStreamIdsFiltered, ({ streamIds }, fileIndex) => streamIds.map(streamId => ['-map', `${fileIndex}:${streamId}`])),
'-map_metadata', '0', '-map_metadata', '0',
// https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata // https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata
@ -210,7 +212,7 @@ async function cut({
async function cutMultiple({ async function cutMultiple({
customOutDir, filePath, segments: segmentsUnsorted, videoDuration, rotation, customOutDir, filePath, segments: segmentsUnsorted, videoDuration, rotation,
onProgress, keyframeCut, copyStreamIds, outFormat, isOutFormatUserSelected, onProgress, keyframeCut, copyStreamIds, outFormat, isOutFormatUserSelected,
appendFfmpegCommandLog, appendFfmpegCommandLog, shortestFlag,
}) { }) {
const segments = sortBy(segmentsUnsorted, 'cutFrom'); const segments = sortBy(segmentsUnsorted, 'cutFrom');
const singleProgresses = {}; const singleProgresses = {};
@ -241,6 +243,7 @@ async function cutMultiple({
keyframeCut, keyframeCut,
cutFrom, cutFrom,
cutTo, cutTo,
shortestFlag,
// eslint-disable-next-line no-loop-func // eslint-disable-next-line no-loop-func
onProgress: progress => onSingleProgress(i, progress), onProgress: progress => onSingleProgress(i, progress),
appendFfmpegCommandLog, appendFfmpegCommandLog,
@ -372,13 +375,17 @@ function determineOutputFormat(ffprobeFormats, ft) {
return ffprobeFormats[0] || undefined; return ffprobeFormats[0] || undefined;
} }
async function getFormat(filePath) { async function getFormatData(filePath) {
console.log('getFormat', filePath); console.log('getFormatData', filePath);
const { stdout } = await runFfprobe([ const { stdout } = await runFfprobe([
'-of', 'json', '-show_format', '-i', filePath, '-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); console.log('formats', formatsStr);
const formats = (formatsStr || '').split(','); const formats = (formatsStr || '').split(',');
@ -504,7 +511,8 @@ function getStreamFps(stream) {
module.exports = { module.exports = {
cutMultiple, cutMultiple,
getFormat, getFormatData,
getDefaultOutFormat,
html5ify, html5ify,
html5ifyDummy, html5ifyDummy,
mergeAnyFiles, mergeAnyFiles,

@ -47,7 +47,10 @@ const ffmpeg = require('./ffmpeg');
const configStore = require('./store'); const configStore = require('./store');
const edlStore = require('./edlStore'); const edlStore = require('./edlStore');
const { defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd } = ffmpeg; const {
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
getDefaultOutFormat, getFormatData,
} = ffmpeg;
const { const {
@ -99,6 +102,7 @@ const App = memo(() => {
const [playerTime, setPlayerTime] = useState(); const [playerTime, setPlayerTime] = useState();
const [duration, setDuration] = useState(); const [duration, setDuration] = useState();
const [fileFormat, setFileFormat] = useState(); const [fileFormat, setFileFormat] = useState();
const [fileFormatData, setFileFormatData] = useState();
const [detectedFileFormat, setDetectedFileFormat] = useState(); const [detectedFileFormat, setDetectedFileFormat] = useState();
const [rotation, setRotation] = useState(360); const [rotation, setRotation] = useState(360);
const [cutProgress, setCutProgress] = useState(); const [cutProgress, setCutProgress] = useState();
@ -116,6 +120,7 @@ const App = memo(() => {
const [debouncedCommandedTime, setDebouncedCommandedTime] = useState(0); const [debouncedCommandedTime, setDebouncedCommandedTime] = useState(0);
const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]); const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]);
const [neighbouringFrames, setNeighbouringFrames] = useState([]); const [neighbouringFrames, setNeighbouringFrames] = useState([]);
const [shortestFlag, setShortestFlag] = useState(false);
// Segment related state // Segment related state
const [currentSegIndex, setCurrentSegIndex] = useState(0); const [currentSegIndex, setCurrentSegIndex] = useState(0);
@ -229,6 +234,7 @@ const App = memo(() => {
setCutStartTimeManual(); setCutStartTimeManual();
setCutEndTimeManual(); setCutEndTimeManual();
setFileFormat(); setFileFormat();
setFileFormatData();
setDetectedFileFormat(); setDetectedFileFormat();
setRotation(360); setRotation(360);
setCutProgress(); setCutProgress();
@ -242,6 +248,7 @@ const App = memo(() => {
setStreamsSelectorShown(false); setStreamsSelectorShown(false);
setZoom(1); setZoom(1);
setNeighbouringFrames([]); setNeighbouringFrames([]);
setShortestFlag(false);
}, [cutSegmentsHistory, setCutSegments, cancelCommandedTimeDebounce]); }, [cutSegmentsHistory, setCutSegments, cancelCommandedTimeDebounce]);
useEffect(() => () => { useEffect(() => () => {
@ -758,6 +765,7 @@ const App = memo(() => {
segments: ffmpegSegments, segments: ffmpegSegments,
onProgress: setCutProgress, onProgress: setCutProgress,
appendFfmpegCommandLog, appendFfmpegCommandLog,
shortestFlag,
}); });
if (outFiles.length > 1 && autoMerge) { if (outFiles.length > 1 && autoMerge) {
@ -798,7 +806,7 @@ const App = memo(() => {
effectiveRotation, outSegments, effectiveRotation, outSegments,
working, duration, filePath, keyframeCut, detectedFileFormat, working, duration, filePath, keyframeCut, detectedFileFormat,
autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyStreamIds, numStreamsToCopy, autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyStreamIds, numStreamsToCopy,
exportExtraStreams, nonCopiedExtraStreams, outputDir, exportExtraStreams, nonCopiedExtraStreams, outputDir, shortestFlag,
]); ]);
// TODO use ffmpeg to capture frame // TODO use ffmpeg to capture frame
@ -871,7 +879,9 @@ const App = memo(() => {
setWorking(true); setWorking(true);
try { try {
const ff = await ffmpeg.getFormat(fp); const fd = await getFormatData(fp);
const ff = await getDefaultOutFormat(fp, fd);
if (!ff) { if (!ff) {
errorToast('Unsupported file'); errorToast('Unsupported file');
return; return;
@ -895,6 +905,7 @@ const App = memo(() => {
setFilePath(fp); setFilePath(fp);
setFileFormat(ff); setFileFormat(ff);
setDetectedFileFormat(ff); setDetectedFileFormat(ff);
setFileFormatData(fd);
if (html5FriendlyPathRequested) { if (html5FriendlyPathRequested) {
setHtml5FriendlyPath(html5FriendlyPathRequested); setHtml5FriendlyPath(html5FriendlyPathRequested);
@ -1003,8 +1014,9 @@ const App = memo(() => {
const addStreamSourceFile = useCallback(async (path) => { const addStreamSourceFile = useCallback(async (path) => {
if (externalStreamFiles[path]) return; if (externalStreamFiles[path]) return;
const { streams } = await ffmpeg.getAllStreams(path); const { streams } = await ffmpeg.getAllStreams(path);
const formatData = await getFormatData(path);
// console.log('streams', streams); // console.log('streams', streams);
setExternalStreamFiles(old => ({ ...old, [path]: { streams } })); setExternalStreamFiles(old => ({ ...old, [path]: { streams, formatData } }));
setCopyStreamIdsForPath(path, () => fromPairs(streams.map(({ index }) => [index, true]))); setCopyStreamIdsForPath(path, () => fromPairs(streams.map(({ index }) => [index, true])));
}, [externalStreamFiles]); }, [externalStreamFiles]);
@ -1546,6 +1558,7 @@ const App = memo(() => {
> >
<StreamsSelector <StreamsSelector
mainFilePath={filePath} mainFilePath={filePath}
mainFileFormatData={fileFormatData}
externalFiles={externalStreamFiles} externalFiles={externalStreamFiles}
setExternalFiles={setExternalStreamFiles} setExternalFiles={setExternalStreamFiles}
showAddStreamSourceDialog={showAddStreamSourceDialog} showAddStreamSourceDialog={showAddStreamSourceDialog}
@ -1554,6 +1567,9 @@ const App = memo(() => {
toggleCopyStreamId={toggleCopyStreamId} toggleCopyStreamId={toggleCopyStreamId}
setCopyStreamIdsForPath={setCopyStreamIdsForPath} setCopyStreamIdsForPath={setCopyStreamIdsForPath}
onExtractAllStreamsPress={onExtractAllStreamsPress} onExtractAllStreamsPress={onExtractAllStreamsPress}
shortestFlag={shortestFlag}
setShortestFlag={setShortestFlag}
exportExtraStreams={exportExtraStreams}
/> />
</SideSheet> </SideSheet>
<Button height={20} iconBefore="list" onClick={withBlur(() => setStreamsSelectorShown(true))}> <Button height={20} iconBefore="list" onClick={withBlur(() => setStreamsSelectorShown(true))}>

Loading…
Cancel
Save