many improvements

- allow include streams from other files #214
- confirm on replace existing file
- allow select more formats
- fix output extension issue
- confirm open and close when editing existing file #229
- improve ui
pull/276/head
Mikael Finstad 6 years ago
parent 581bc52868
commit f73f7691e1

@ -1,6 +1,6 @@
import React, { memo } from 'react';
import React, { memo, Fragment } from 'react';
import { FaVideo, FaVideoSlash, FaFileExport, FaVolumeUp, FaVolumeMute, FaBan } from 'react-icons/fa';
import { FaVideo, FaVideoSlash, FaFileExport, FaFileImport, FaVolumeUp, FaVolumeMute, FaBan, FaTrashAlt } from 'react-icons/fa';
import { GoFileBinary } from 'react-icons/go';
import { MdSubtitles } from 'react-icons/md';
@ -8,16 +8,62 @@ const { formatDuration } = require('./util');
const { getStreamFps } = require('./ffmpeg');
const Stream = memo(({ stream, onToggle, copyStream }) => {
const bitrate = parseInt(stream.bit_rate, 10);
const duration = parseInt(stream.duration, 10);
let Icon;
if (stream.codec_type === 'audio') {
Icon = copyStream ? FaVolumeUp : FaVolumeMute;
} else if (stream.codec_type === 'video') {
Icon = copyStream ? FaVideo : FaVideoSlash;
} else if (stream.codec_type === 'subtitle') {
Icon = copyStream ? MdSubtitles : FaBan;
} else {
Icon = copyStream ? GoFileBinary : FaBan;
}
const streamFps = getStreamFps(stream);
return (
<tr
style={{ opacity: copyStream ? undefined : 0.4 }}
onClick={() => onToggle && onToggle(stream.index)}
>
<td><Icon size={20} style={{ padding: '0 5px', cursor: 'pointer' }} /></td>
<td>{stream.index}</td>
<td>{stream.codec_type}</td>
<td>{stream.codec_tag !== '0x0000' && stream.codec_tag_string}</td>
<td>{stream.codec_name}</td>
<td>{!Number.isNaN(duration) && `${formatDuration({ seconds: duration })}`}</td>
<td>{stream.nb_frames}</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(1)}fps`}</td>
</tr>
);
});
const StreamsSelector = memo(({
streams, copyStreamIds, toggleCopyStreamId, onExtractAllStreamsPress,
mainFilePath, streams: existingStreams, isCopyingStreamId, toggleCopyStreamId,
setCopyStreamIdsForPath, onExtractAllStreamsPress, externalFiles, setExternalFiles,
showAddStreamSourceDialog,
}) => {
if (!streams) return null;
if (!existingStreams) return null;
async function removeFile(path) {
setCopyStreamIdsForPath(path, () => ({}));
setExternalFiles((old) => {
const { [path]: val, ...rest } = old;
return rest;
});
}
return (
<div style={{ color: 'black', padding: 10 }}>
<p>Click to select which tracks to keep:</p>
<p>Click to select which tracks to keep when exporting:</p>
<table>
<table style={{ marginBottom: 10 }}>
<thead style={{ background: 'rgba(0,0,0,0.1)' }}>
<tr>
<td />
@ -32,49 +78,45 @@ const StreamsSelector = memo(({
</tr>
</thead>
<tbody>
{streams.map((stream) => {
const bitrate = parseInt(stream.bit_rate, 10);
const duration = parseInt(stream.duration, 10);
function onToggle() {
toggleCopyStreamId(stream.index);
}
const copyStream = copyStreamIds[stream.index];
let Icon;
if (stream.codec_type === 'audio') {
Icon = copyStream ? FaVolumeUp : FaVolumeMute;
} else if (stream.codec_type === 'video') {
Icon = copyStream ? FaVideo : FaVideoSlash;
} else if (stream.codec_type === 'subtitle') {
Icon = copyStream ? MdSubtitles : FaBan;
} else {
Icon = copyStream ? GoFileBinary : FaBan;
}
const streamFps = getStreamFps(stream);
return (
<tr key={stream.index} style={{ opacity: copyStream ? undefined : 0.4 }} onClick={onToggle}>
<td><Icon size={20} style={{ padding: '0 5px', cursor: 'pointer' }} /></td>
<td>{stream.index}</td>
<td>{stream.codec_type}</td>
<td>{stream.codec_tag !== '0x0000' && stream.codec_tag_string}</td>
<td>{stream.codec_name}</td>
<td>{!Number.isNaN(duration) && `${formatDuration({ seconds: duration })}`}</td>
<td>{stream.nb_frames}</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(1)}fps`}</td>
{existingStreams.map((stream) => (
<Stream
key={stream.index}
stream={stream}
copyStream={isCopyingStreamId(mainFilePath, stream.index)}
onToggle={(streamId) => toggleCopyStreamId(mainFilePath, streamId)}
/>
))}
{Object.entries(externalFiles).map(([path, { streams }]) => (
<Fragment key={path}>
<tr>
<td colSpan={9} style={{ paddingTop: 15 }}>
{path} <FaTrashAlt role="button" onClick={() => removeFile(path)} />
</td>
</tr>
);
})}
{streams.map((stream) => (
<Stream
key={stream.index}
stream={stream}
copyStream={isCopyingStreamId(path, stream.index)}
onToggle={(streamId) => toggleCopyStreamId(path, streamId)}
/>
))}
</Fragment>
))}
</tbody>
</table>
<div style={{ cursor: 'pointer', padding: 20 }} role="button" onClick={onExtractAllStreamsPress}>
<FaFileExport size={30} style={{ verticalAlign: 'middle' }} /> Export each track as individual files
<div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={showAddStreamSourceDialog}>
<FaFileImport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> Include tracks from other file
</div>
{Object.keys(externalFiles).length === 0 && (
<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
</div>
)}
</div>
);
});

@ -1,9 +1,10 @@
const execa = require('execa');
const pMap = require('p-map');
const path = require('path');
const { join, extname } = require('path');
const fileType = require('file-type');
const readChunk = require('read-chunk');
const flatMap = require('lodash/flatMap');
const flatMapDeep = require('lodash/flatMapDeep');
const sum = require('lodash/sum');
const sortBy = require('lodash/sortBy');
const readline = require('readline');
@ -30,7 +31,7 @@ function getPath(type) {
return isDev
? `node_modules/${type}-static/bin/${subPath}`
: path.join(window.process.resourcesPath, `node_modules/${type}-static/bin/${subPath}`);
: join(window.process.resourcesPath, `node_modules/${type}-static/bin/${subPath}`);
}
async function runFfprobe(args) {
@ -62,8 +63,16 @@ function handleProgress(process, cutDuration, onProgress) {
});
}
function isCuttingStart(cutFrom) {
return cutFrom > 0;
}
function isCuttingEnd(cutTo, duration) {
return cutTo < duration;
}
async function cut({
filePath, format, cutFrom, cutTo, videoDuration, rotation,
filePath, outFormat, cutFrom, cutTo, videoDuration, rotation,
onProgress, copyStreamIds, keyframeCut, outPath,
}) {
console.log('Cutting from', cutFrom, 'to', cutTo);
@ -71,16 +80,19 @@ async function cut({
const cutDuration = cutTo - cutFrom;
// https://github.com/mifi/lossless-cut/issues/50
const cutFromArgs = cutFrom === 0 ? [] : ['-ss', cutFrom];
const cutToArgs = cutTo === videoDuration ? [] : ['-t', cutDuration];
const cutFromArgs = isCuttingStart(cutFrom) ? ['-ss', cutFrom] : [];
const cutToArgs = isCuttingEnd(cutTo, videoDuration) ? ['-t', cutDuration] : [];
const copyStreamIdsFiltered = copyStreamIds.filter(({ streamIds }) => streamIds.length > 0);
const inputArgs = flatMap(copyStreamIdsFiltered, ({ path }) => ['-i', path]);
const inputCutArgs = keyframeCut ? [
...cutFromArgs,
'-i', filePath,
...inputArgs,
...cutToArgs,
'-avoid_negative_ts', 'make_zero',
] : [
'-i', filePath,
...inputArgs,
...cutFromArgs,
...cutToArgs,
];
@ -92,7 +104,7 @@ async function cut({
'-c', 'copy',
...flatMap(Object.keys(copyStreamIds).filter(index => copyStreamIds[index]), index => ['-map', `0:${index}`]),
...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
'-movflags', 'use_metadata_tags',
@ -102,7 +114,7 @@ async function cut({
...rotationArgs,
'-f', format, '-y', outPath,
'-f', outFormat, '-y', outPath,
];
console.log('ffmpeg', ffmpegArgs.join(' '));
@ -119,8 +131,8 @@ async function cut({
}
async function cutMultiple({
customOutDir, filePath, format, segments: segmentsUnsorted, videoDuration, rotation,
onProgress, keyframeCut, copyStreamIds,
customOutDir, filePath, segments: segmentsUnsorted, videoDuration, rotation,
onProgress, keyframeCut, copyStreamIds, outFormat, isOutFormatUserSelected,
}) {
const segments = sortBy(segmentsUnsorted, 'cutFrom');
const singleProgresses = {};
@ -134,7 +146,7 @@ async function cutMultiple({
let i = 0;
// eslint-disable-next-line no-restricted-syntax,no-unused-vars
for (const { cutFrom, cutTo } of segments) {
const ext = path.extname(filePath) || `.${format}`;
const ext = isOutFormatUserSelected ? `.${outFormat}` : extname(filePath);
const cutSpecification = `${formatDuration({ seconds: cutFrom, fileNameFriendly: true })}-${formatDuration({ seconds: cutTo, fileNameFriendly: true })}`;
const outPath = getOutPath(customOutDir, filePath, `${cutSpecification}${ext}`);
@ -144,7 +156,7 @@ async function cutMultiple({
outPath,
customOutDir,
filePath,
format,
outFormat,
videoDuration,
rotation,
copyStreamIds,
@ -235,7 +247,7 @@ async function mergeFiles({ paths, outPath }) {
console.log('ffmpeg', ffmpegArgs.join(' '));
// https://superuser.com/questions/787064/filename-quoting-in-ffmpeg-concat
const concatTxt = paths.map(file => `file '${path.join(file).replace(/'/g, "'\\''")}'`).join('\n');
const concatTxt = paths.map(file => `file '${join(file).replace(/'/g, "'\\''")}'`).join('\n');
console.log(concatTxt);
@ -250,13 +262,13 @@ async function mergeFiles({ paths, outPath }) {
async function mergeAnyFiles({ customOutDir, paths }) {
const firstPath = paths[0];
const ext = path.extname(firstPath);
const ext = extname(firstPath);
const outPath = getOutPath(customOutDir, firstPath, `merged${ext}`);
return mergeFiles({ paths, outPath });
}
async function autoMergeSegments({ customOutDir, sourceFile, segmentPaths }) {
const ext = path.extname(sourceFile);
const ext = extname(sourceFile);
const outPath = getOutPath(customOutDir, sourceFile, `cut-merged-${new Date().getTime()}${ext}`);
await mergeFiles({ paths: segmentPaths, outPath });
await pMap(segmentPaths, trash, { concurrency: 5 });
@ -280,6 +292,15 @@ function mapFormat(requestedFormat) {
}
}
function getExtensionForFormat(format) {
const ext = {
matroska: 'mkv',
ipod: 'm4a',
}[format];
return ext || format;
}
function determineOutputFormat(ffprobeFormats, ft) {
if (ffprobeFormats.includes(ft.ext)) return ft.ext;
return ffprobeFormats[0] || undefined;
@ -311,26 +332,27 @@ async function getAllStreams(filePath) {
return JSON.parse(stdout);
}
function mapCodecToOutputFormat(codec, type) {
function getPreferredCodecFormat(codec, type) {
const map = {
// See mapFormat
m4a: { ext: 'm4a', format: 'ipod' },
aac: { ext: 'm4a', format: 'ipod' },
mp3: 'mp3',
opus: 'opus',
vorbis: 'ogg',
h264: 'mp4',
hevc: 'mp4',
eac3: 'eac3',
mp3: { ext: 'mp3', format: 'mp3' },
opus: { ext: 'opus', format: 'opus' },
vorbis: { ext: 'ogg', format: 'ogg' },
h264: { ext: 'mp4', format: 'mp4' },
hevc: { ext: 'mp4', format: 'mp4' },
eac3: { ext: 'eac3', format: 'eac3' },
subrip: 'srt',
subrip: { ext: 'srt', format: 'srt' },
// See mapFormat
m4a: 'ipod',
aac: 'ipod',
// TODO add more
// TODO allow user to change?
};
if (map[codec]) return map[codec];
const format = map[codec];
if (format) return { format, ext: getExtensionForFormat(format) };
if (type === 'video') return { ext: 'mkv', format: 'matroska' };
if (type === 'audio') return { ext: 'mka', format: 'matroska' };
if (type === 'subtitle') return { ext: 'mks', format: 'matroska' };
@ -347,7 +369,7 @@ async function extractAllStreams({ customOutDir, filePath }) {
i,
codec: s.codec_name || s.codec_tag_string || s.codec_type,
type: s.codec_type,
format: mapCodecToOutputFormat(s.codec_name, s.codec_type),
format: getPreferredCodecFormat(s.codec_name, s.codec_type),
}))
.filter(it => it && it.format);
@ -429,4 +451,6 @@ module.exports = {
getAllStreams,
defaultProcessedCodecTypes,
getStreamFps,
isCuttingStart,
isCuttingEnd,
};

@ -17,7 +17,7 @@ module.exports = (app, mainWindow, newVersion) => {
label: 'Open',
accelerator: 'CmdOrCtrl+O',
async click() {
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] });
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] });
if (canceled) return;
mainWindow.webContents.send('file-opened', filePaths);
},

@ -0,0 +1,165 @@
// Extracted from "ffmpeg -formats"
module.exports = {
'3g2': '3GP2 (3GPP2 file format)',
'3gp': '3GP (3GPP file format)',
a64: 'a64 - video for Commodore 64',
ac3: 'raw AC-3',
adts: 'ADTS AAC (Advanced Audio Coding)',
adx: 'CRI ADX',
aiff: 'Audio IFF',
alaw: 'PCM A-law',
amr: '3GPP AMR',
apng: 'Animated Portable Network Graphics',
aptx: 'raw aptX (Audio Processing Technology for Bluetooth)',
aptx_hd: 'raw aptX HD (Audio Processing Technology for Bluetooth)',
asf: 'ASF (Advanced / Active Streaming Format)',
asf_stream: 'ASF (Advanced / Active Streaming Format)',
ass: 'SSA (SubStation Alpha) subtitle',
ast: 'AST (Audio Stream)',
au: 'Sun AU',
avi: 'AVI (Audio Video Interleaved)',
avm2: 'SWF (ShockWave Flash) (AVM2)',
avs2: 'raw AVS2-P2/IEEE1857.4 video',
bit: 'G.729 BIT file format',
caf: 'Apple CAF (Core Audio Format)',
cavsvideo: 'raw Chinese AVS (Audio Video Standard) video',
codec2: 'codec2 .c2 muxer',
codec2raw: 'raw codec2 muxer',
crc: 'CRC testing',
dash: 'DASH Muxer',
data: 'raw data',
daud: 'D-Cinema audio',
dirac: 'raw Dirac',
dnxhd: 'raw DNxHD (SMPTE VC-3)',
dts: 'raw DTS',
dv: 'DV (Digital Video)',
dvd: 'MPEG-2 PS (DVD VOB)',
eac3: 'raw E-AC-3',
f32be: 'PCM 32-bit floating-point big-endian',
f32le: 'PCM 32-bit floating-point little-endian',
f4v: 'F4V Adobe Flash Video',
f64be: 'PCM 64-bit floating-point big-endian',
f64le: 'PCM 64-bit floating-point little-endian',
ffmetadata: 'FFmpeg metadata in text',
fifo: 'FIFO queue pseudo-muxer',
fifo_test: 'Fifo test muxer',
film_cpk: 'Sega FILM / CPK',
filmstrip: 'Adobe Filmstrip',
fits: 'Flexible Image Transport System',
flac: 'raw FLAC',
flv: 'FLV (Flash Video)',
framecrc: 'framecrc testing',
framehash: 'Per-frame hash testing',
framemd5: 'Per-frame MD5 testing',
g722: 'raw G.722',
g723_1: 'raw G.723.1',
g726: 'raw big-endian G.726 ("left-justified")',
g726le: 'raw little-endian G.726 ("right-justified")',
gif: 'CompuServe Graphics Interchange Format (GIF)',
gsm: 'raw GSM',
gxf: 'GXF (General eXchange Format)',
h261: 'raw H.261',
h263: 'raw H.263',
h264: 'raw H.264 video',
hash: 'Hash testing',
hds: 'HDS Muxer',
hevc: 'raw HEVC video',
hls: 'Apple HTTP Live Streaming',
ico: 'Microsoft Windows ICO',
ilbc: 'iLBC storage',
image2: 'image2 sequence',
image2pipe: 'piped image2 sequence',
ipod: 'iPod H.264 MP4 (MPEG-4 Part 14)',
ircam: 'Berkeley/IRCAM/CARL Sound Format',
ismv: 'ISMV/ISMA (Smooth Streaming)',
ivf: 'On2 IVF',
jacosub: 'JACOsub subtitle format',
latm: 'LOAS/LATM',
lrc: 'LRC lyrics',
m4v: 'raw MPEG-4 video',
matroska: 'Matroska',
md5: 'MD5 testing',
microdvd: 'MicroDVD subtitle format',
mjpeg: 'raw MJPEG video',
mkvtimestamp_v2: 'extract pts as timecode v2 format, as defined by mkvtoolnix',
mlp: 'raw MLP',
mmf: 'Yamaha SMAF',
mov: 'QuickTime / MOV',
mp2: 'MP2 (MPEG audio layer 2)',
mp3: 'MP3 (MPEG audio layer 3)',
mp4: 'MP4 (MPEG-4 Part 14)',
mpeg: 'MPEG-1 Systems / MPEG program stream',
mpeg1video: 'raw MPEG-1 video',
mpeg2video: 'raw MPEG-2 video',
mpegts: 'MPEG-TS (MPEG-2 Transport Stream)',
mpjpeg: 'MIME multipart JPEG',
mulaw: 'PCM mu-law',
mxf: 'MXF (Material eXchange Format)',
mxf_d10: 'MXF (Material eXchange Format) D-10 Mapping',
mxf_opatom: 'MXF (Material eXchange Format) Operational Pattern Atom',
null: 'raw null video',
nut: 'NUT',
oga: 'Ogg Audio',
ogg: 'Ogg',
ogv: 'Ogg Video',
oma: 'Sony OpenMG audio',
opus: 'Ogg Opus',
psp: 'PSP MP4 (MPEG-4 Part 14)',
rawvideo: 'raw video',
rm: 'RealMedia',
roq: 'raw id RoQ',
rso: 'Lego Mindstorms RSO',
rtp: 'RTP output',
rtp_mpegts: 'RTP/mpegts output format',
rtsp: 'RTSP output',
s16be: 'PCM signed 16-bit big-endian',
s16le: 'PCM signed 16-bit little-endian',
s24be: 'PCM signed 24-bit big-endian',
s24le: 'PCM signed 24-bit little-endian',
s32be: 'PCM signed 32-bit big-endian',
s32le: 'PCM signed 32-bit little-endian',
s8: 'PCM signed 8-bit',
sap: 'SAP output',
sbc: 'raw SBC',
scc: 'Scenarist Closed Captions',
sdl: 'SDL2 output device',
segment: 'segment',
singlejpeg: 'JPEG single image',
smjpeg: 'Loki SDL MJPEG',
smoothstreaming: 'Smooth Streaming Muxer',
sox: 'SoX native',
spdif: 'IEC 61937 (used on S/PDIF - IEC958)',
spx: 'Ogg Speex',
srt: 'SubRip subtitle',
ssegment: 'streaming segment muxer',
sup: 'raw HDMV Presentation Graphic Stream subtitles',
svcd: 'MPEG-2 PS (SVCD)',
swf: 'SWF (ShockWave Flash)',
tee: 'Multiple muxer tee',
truehd: 'raw TrueHD',
tta: 'TTA (True Audio)',
u16be: 'PCM unsigned 16-bit big-endian',
u16le: 'PCM unsigned 16-bit little-endian',
u24be: 'PCM unsigned 24-bit big-endian',
u24le: 'PCM unsigned 24-bit little-endian',
u32be: 'PCM unsigned 32-bit big-endian',
u32le: 'PCM unsigned 32-bit little-endian',
u8: 'PCM unsigned 8-bit',
uncodedframecrc: 'uncoded framecrc testing',
vc1: 'raw VC-1 video',
vc1test: 'VC-1 test bitstream',
vcd: 'MPEG-1 Systems / MPEG program stream (VCD)',
vidc: 'PCM Archimedes VIDC',
vob: 'MPEG-2 PS (VOB)',
voc: 'Creative Voice',
w64: 'Sony Wave64',
wav: 'WAV / WAVE (Waveform Audio)',
webm: 'WebM',
webm_chunk: 'WebM Chunk Muxer',
webm_dash_manifest: 'WebM DASH Manifest',
webp: 'WebP',
webvtt: 'WebVTT subtitle',
wtv: 'Windows Television (WTV)',
wv: 'raw WavPack',
yuv4mpegpipe: 'YUV4MPEG pipe',
};

@ -1,16 +1,18 @@
import React, { memo, useEffect, useState, useCallback, useRef } from 'react';
import { IoIosHelpCircle, IoIosCamera } from 'react-icons/io';
import { FaPlus, FaMinus, FaAngleLeft, FaAngleRight, FaTrashAlt, FaVolumeMute, FaVolumeUp, FaYinYang } from 'react-icons/fa';
import { FaPlus, FaMinus, FaAngleLeft, FaAngleRight, FaTrashAlt, FaVolumeMute, FaVolumeUp, FaYinYang, FaFileExport } from 'react-icons/fa';
import { MdRotate90DegreesCcw } from 'react-icons/md';
import { GiYinYang } from 'react-icons/gi';
import { FiScissors } from 'react-icons/fi';
import { AnimatePresence, motion } from 'framer-motion';
import Swal from 'sweetalert2';
import { Popover, Button } from 'evergreen-ui';
import { SideSheet, Button, Position } from 'evergreen-ui';
import fromPairs from 'lodash/fromPairs';
import clamp from 'lodash/clamp';
import clone from 'lodash/clone';
import sortBy from 'lodash/sortBy';
import flatMap from 'lodash/flatMap';
import HelpSheet from './HelpSheet';
import TimelineSeg from './TimelineSeg';
@ -23,7 +25,7 @@ const isDev = require('electron-is-dev');
const electron = require('electron'); // eslint-disable-line
const Mousetrap = require('mousetrap');
const Hammer = require('react-hammerjs').default;
const path = require('path');
const { dirname } = require('path');
const trash = require('trash');
const uuid = require('uuid');
@ -33,11 +35,11 @@ const { unlink, exists } = require('fs-extra');
const { showMergeDialog, showOpenAndMergeDialog } = require('./merge/merge');
const allOutFormats = require('./outFormats');
const captureFrame = require('./capture-frame');
const ffmpeg = require('./ffmpeg');
const { defaultProcessedCodecTypes, getStreamFps } = ffmpeg;
const { defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd } = ffmpeg;
const {
@ -94,10 +96,12 @@ const App = memo(() => {
const [startTimeOffset, setStartTimeOffset] = useState(0);
const [rotationPreviewRequested, setRotationPreviewRequested] = useState(false);
const [filePath, setFilePath] = useState('');
const [externalStreamFiles, setExternalStreamFiles] = useState([]);
const [detectedFps, setDetectedFps] = useState();
const [streams, setStreams] = useState([]);
const [copyStreamIds, setCopyStreamIds] = useState({});
const [mainStreams, setStreams] = useState([]);
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({});
const [muted, setMuted] = useState(false);
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
// Global state
const [captureFormat, setCaptureFormat] = useState('jpeg');
@ -113,8 +117,15 @@ const App = memo(() => {
const timelineWrapperRef = useRef();
function toggleCopyStreamId(index) {
setCopyStreamIds(v => ({ ...v, [index]: !v[index] }));
function setCopyStreamIdsForPath(path, cb) {
setCopyStreamIdsByFile((old) => {
const oldIds = old[path] || {};
return ({ ...old, [path]: cb(oldIds) });
});
}
function toggleCopyStreamId(path, index) {
setCopyStreamIdsForPath(path, (old) => ({ ...old, [index]: !old[index] }));
}
function toggleMute() {
@ -166,11 +177,13 @@ const App = memo(() => {
setStartTimeOffset(0);
setRotationPreviewRequested(false);
setFilePath(''); // Setting video src="" prevents memory leak in chromium
setExternalStreamFiles([]);
setDetectedFps();
setStreams([]);
setCopyStreamIds({});
setCopyStreamIdsByFile({});
setMuted(false);
setInvertCutSegments(false);
setStreamsSelectorShown(false);
}, []);
useEffect(() => () => {
@ -205,6 +218,9 @@ const App = memo(() => {
const currentCutSeg = cutSegments[currentSegIndex];
const currentApparentCutSeg = apparentCutSegments[currentSegIndex];
const areWeCutting = apparentCutSegments.length > 1
|| isCuttingStart(currentApparentCutSeg.start)
|| isCuttingEnd(currentApparentCutSeg.end, duration);
const inverseCutSegments = (() => {
if (haveInvalidSegs) return undefined;
@ -305,7 +321,7 @@ const App = memo(() => {
function getOutputDir() {
if (customOutDir) return customOutDir;
if (filePath) return path.dirname(filePath);
if (filePath) return dirname(filePath);
return undefined;
}
@ -383,11 +399,29 @@ const App = memo(() => {
const toggleKeyframeCut = () => setKeyframeCut(val => !val);
const toggleAutoMerge = () => setAutoMerge(val => !val);
const copyAnyAudioTrack = streams.some(stream => copyStreamIds[stream.index] && stream.codec_type === 'audio');
const isCopyingStreamId = useCallback((path, streamId) => (
!!(copyStreamIdsByFile[path] || {})[streamId]
), [copyStreamIdsByFile]);
const copyAnyAudioTrack = mainStreams.some(stream => isCopyingStreamId(filePath, stream.index) && stream.codec_type === 'audio');
const copyStreamIds = Object.entries(copyStreamIdsByFile).map(([path, streamIdsMap]) => ({
path,
streamIds: Object.keys(streamIdsMap).filter(index => streamIdsMap[index]),
}));
const numStreamsToCopy = copyStreamIds
.reduce((acc, { streamIds }) => acc + streamIds.length, 0);
const numStreamsTotal = [
...mainStreams,
...flatMap(Object.values(externalStreamFiles), ({ streams }) => streams),
].length;
function toggleStripAudio() {
setCopyStreamIds((old) => {
setCopyStreamIdsForPath(filePath, (old) => {
const newCopyStreamIds = { ...old };
streams.forEach((stream) => {
mainStreams.forEach((stream) => {
if (stream.codec_type === 'audio') newCopyStreamIds[stream.index] = !copyAnyAudioTrack;
});
return newCopyStreamIds;
@ -478,7 +512,8 @@ const App = memo(() => {
const outFiles = await ffmpeg.cutMultiple({
customOutDir,
filePath,
format: fileFormat,
outFormat: fileFormat,
isOutFormatUserSelected: fileFormat !== detectedFileFormat,
videoDuration: duration,
rotation: effectiveRotation,
copyStreamIds,
@ -497,13 +532,13 @@ const App = memo(() => {
});
}
toast.fire({ timer: 10000, type: 'success', title: `Cut completed! Output file(s) can be found at: ${getOutDir(customOutDir, filePath)}. You can change the output directory in settings` });
toast.fire({ timer: 10000, type: 'success', title: `Export completed! Output file(s) can be found at: ${getOutDir(customOutDir, filePath)}. You can change the output directory in settings` });
} catch (err) {
console.error('stdout:', err.stdout);
console.error('stderr:', err.stderr);
if (err.code === 1 || err.code === 'ENOENT') {
errorToast(`Whoops! ffmpeg was unable to cut this video. Try each the following things before attempting to cut again:\n1. Select a different output format from the ${fileFormat} button (matroska takes almost everything).\n2. toggle the button "all" to "ps"`);
errorToast(`Whoops! ffmpeg was unable to export this video. Try one of the following before exporting again:\n1. Select a different output format from the ${fileFormat} button (matroska takes almost everything).\n2. Exclude unnecessary tracks`);
return;
}
@ -513,12 +548,12 @@ const App = memo(() => {
}
}, [
effectiveRotation, apparentCutSegments, invertCutSegments, inverseCutSegments,
working, duration, filePath, keyframeCut,
autoMerge, customOutDir, fileFormat, copyStreamIds, haveInvalidSegs,
working, duration, filePath, keyframeCut, detectedFileFormat,
autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyStreamIds,
]);
function showUnsupportedFileMessage() {
toast.fire({ timer: 10000, type: 'warning', title: 'This video is not natively supported', text: 'This means that there is no audio in the preview and it has low quality. The final cut operation will however be lossless and contains audio!' });
toast.fire({ timer: 10000, type: 'warning', title: 'This video is not natively supported', text: 'This means that there is no audio in the preview and it has low quality. The final export operation will however be lossless and contains audio!' });
}
// TODO use ffmpeg to capture frame
@ -586,15 +621,15 @@ const App = memo(() => {
return;
}
const { streams: streamsNew } = await ffmpeg.getAllStreams(fp);
console.log('streams', streamsNew);
setStreams(streamsNew);
setCopyStreamIds(fromPairs(streamsNew.map((stream) => [
const { streams } = await ffmpeg.getAllStreams(fp);
// console.log('streams', streamsNew);
setStreams(streams);
setCopyStreamIdsForPath(fp, () => fromPairs(streams.map((stream) => [
stream.index, defaultProcessedCodecTypes.includes(stream.codec_type),
])));
streamsNew.find((stream) => {
streams.find((stream) => {
const streamFps = getStreamFps(stream);
if (streamFps != null) {
setDetectedFps(streamFps);
@ -613,7 +648,7 @@ const App = memo(() => {
showUnsupportedFileMessage();
} else if (
!(await checkExistingHtml5FriendlyFile(fp, 'slow-audio') || await checkExistingHtml5FriendlyFile(fp, 'slow') || await checkExistingHtml5FriendlyFile(fp, 'fast'))
&& !doesPlayerSupportFile(streamsNew)
&& !doesPlayerSupportFile(streams)
) {
await createDummyVideo(fp);
}
@ -697,13 +732,61 @@ const App = memo(() => {
extractAllStreams();
}
const addStreamSourceFile = useCallback(async (path) => {
if (externalStreamFiles[path]) return;
const { streams } = await ffmpeg.getAllStreams(path);
// console.log('streams', streams);
setExternalStreamFiles(old => ({ ...old, [path]: { streams } }));
setCopyStreamIdsForPath(path, () => fromPairs(streams.map(({ index }) => [index, true])));
}, [externalStreamFiles]);
const userOpenFiles = useCallback(async (filePaths) => {
if (filePaths.length < 1) return;
if (filePaths.length > 1) {
showMergeDialog(filePaths, mergeFiles);
return;
}
const firstFile = filePaths[0];
if (!filePath) {
load(firstFile);
return;
}
const { value } = await Swal.fire({
title: 'You opened a new file. What do you want to do?',
input: 'radio',
showCancelButton: true,
inputOptions: {
open: 'Open the file instead of the current one. You will lose all work',
add: 'Include all tracks from the new file',
},
inputValidator: (v) => !v && 'You need to choose something!',
});
if (value === 'open') {
load(firstFile);
} else if (value === 'add') {
addStreamSourceFile(firstFile);
setStreamsSelectorShown(true);
}
}, [addStreamSourceFile, filePath, load, mergeFiles]);
const onDrop = useCallback(async (ev) => {
ev.preventDefault();
const { files } = ev.dataTransfer;
userOpenFiles(Array.from(files).map(f => f.path));
}, [userOpenFiles]);
useEffect(() => {
function fileOpened(event, filePaths) {
if (!filePaths || filePaths.length !== 1) return;
load(filePaths[0]);
userOpenFiles(filePaths);
}
function closeFile() {
// eslint-disable-next-line no-alert
if (!window.confirm('Are you sure you want to replace the current file? You will lose all work')) return;
resetState();
}
@ -756,7 +839,7 @@ const App = memo(() => {
return () => {
electron.ipcRenderer.removeListener('file-opened', fileOpened);
electron.ipcRenderer.removeListener('close-file', fileOpened);
electron.ipcRenderer.removeListener('close-file', closeFile);
electron.ipcRenderer.removeListener('html5ify', html5ify);
electron.ipcRenderer.removeListener('show-merge-dialog', showOpenAndMergeDialog2);
electron.ipcRenderer.removeListener('set-start-offset', setStartOffset);
@ -764,16 +847,14 @@ const App = memo(() => {
};
}, [
load, mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, getHtml5ifiedPath,
createDummyVideo, resetState, extractAllStreams,
createDummyVideo, resetState, extractAllStreams, userOpenFiles,
]);
const onDrop = useCallback((ev) => {
ev.preventDefault();
const { files } = ev.dataTransfer;
if (files.length < 1) return;
if (files.length === 1) load(files[0].path);
else showMergeDialog(Array.from(files).map(f => f.path), mergeFiles);
}, [load, mergeFiles]);
async function showAddStreamSourceDialog() {
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] });
if (canceled || filePaths.length < 1) return;
await addStreamSourceFile(filePaths[0]);
}
useEffect(() => {
document.body.addEventListener('drop', onDrop);
@ -822,7 +903,11 @@ const App = memo(() => {
);
}
const selectableFormats = ['mov', 'mp4', 'matroska'].filter(f => f !== detectedFileFormat);
const commonFormats = ['mov', 'mp4', 'matroska', 'mp3', 'ipod'];
const commonFormatsMap = fromPairs(commonFormats.map(format => [format, allOutFormats[format]])
.filter(([f]) => f !== detectedFileFormat));
const otherFormatsMap = fromPairs(Object.entries(allOutFormats)
.filter(([f]) => ![...commonFormats, detectedFileFormat].includes(f)));
const durationSafe = duration || 1;
const currentTimePos = currentTime !== undefined && `${(currentTime / durationSafe) * 100}%`;
@ -834,16 +919,27 @@ const App = memo(() => {
position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px',
};
function renderFormatOptions(map) {
return Object.entries(map).map(([f, name]) => (
<option key={f} value={f}>{f} - {name}</option>
));
}
function renderOutFmt({ width } = {}) {
return (
<select style={{ width }} defaultValue="" value={fileFormat} title="Format of current file" onChange={withBlur(e => setFileFormat(e.target.value))}>
<option key="" value="" disabled>Out fmt</option>
<select style={{ width }} defaultValue="" value={fileFormat} title="Output format" onChange={withBlur(e => setFileFormat(e.target.value))}>
<option key="disabled1" value="" disabled>Format</option>
{detectedFileFormat && (
<option key={detectedFileFormat} value={detectedFileFormat}>
{detectedFileFormat}
{detectedFileFormat} - {allOutFormats[detectedFileFormat]} (detected)
</option>
)}
{selectableFormats.map(f => <option key={f} value={f}>{f}</option>)}
<option key="disabled2" value="" disabled>--- Common formats: ---</option>
{renderFormatOptions(commonFormatsMap)}
<option key="disabled3" value="" disabled>--- All formats: ---</option>
{renderFormatOptions(otherFormatsMap)}
</select>
);
}
@ -922,14 +1018,14 @@ const App = memo(() => {
<tr>
<td>
Delete audio?
Discard audio?
</td>
<td>
<button
type="button"
onClick={toggleStripAudio}
>
{!copyAnyAudioTrack ? 'Delete all audio tracks' : 'Keep audio tracks'}
{!copyAnyAudioTrack ? 'Discard all audio tracks' : 'Keep audio tracks'}
</button>
</td>
</tr>
@ -973,6 +1069,7 @@ const App = memo(() => {
const bottomBarHeight = '6rem';
const VolumeIcon = muted ? FaVolumeMute : FaVolumeUp;
const CutIcon = areWeCutting ? FiScissors : FaFileExport;
function renderInvertCutButton() {
const KeepOrDiscardIcon = invertCutSegments ? GiYinYang : FaYinYang;
@ -996,21 +1093,38 @@ const App = memo(() => {
return (
<div>
<div style={{ background: '#6b6b6b', height: topBarHeight, display: 'flex', alignItems: 'center', padding: '0 5px', justifyContent: 'space-between' }}>
<Popover
content={(
<StreamsSelector
streams={streams}
copyStreamIds={copyStreamIds}
toggleCopyStreamId={toggleCopyStreamId}
onExtractAllStreamsPress={onExtractAllStreamsPress}
/>
)}
<SideSheet
containerProps={{ style: { maxWidth: '100%' } }}
position={Position.LEFT}
isShown={streamsSelectorShown}
onCloseComplete={() => setStreamsSelectorShown(false)}
>
<Button height={20} iconBefore="list">Tracks</Button>
</Popover>
<StreamsSelector
mainFilePath={filePath}
externalFiles={externalStreamFiles}
setExternalFiles={setExternalStreamFiles}
showAddStreamSourceDialog={showAddStreamSourceDialog}
streams={mainStreams}
isCopyingStreamId={isCopyingStreamId}
toggleCopyStreamId={toggleCopyStreamId}
setCopyStreamIdsForPath={setCopyStreamIdsForPath}
onExtractAllStreamsPress={onExtractAllStreamsPress}
/>
</SideSheet>
<Button height={20} iconBefore="list" onClick={withBlur(() => setStreamsSelectorShown(true))}>
Tracks ({numStreamsToCopy}/{numStreamsTotal})
</Button>
<div style={{ flexGrow: 1 }} />
<button
type="button"
onClick={withBlur(setOutputDir)}
title={customOutDir}
>
{`Out path ${customOutDir ? 'set' : 'unset'}`}
</button>
{renderOutFmt({ width: 60 })}
<button
@ -1032,10 +1146,10 @@ const App = memo(() => {
<button
type="button"
title={`Delete audio? Current: ${copyAnyAudioTrack ? 'keep audio tracks' : 'delete audio tracks'}`}
title={`Discard audio? Current: ${copyAnyAudioTrack ? 'keep audio tracks' : 'Discard audio tracks'}`}
onClick={withBlur(toggleStripAudio)}
>
{copyAnyAudioTrack ? 'Keep audio' : 'Delete audio'}
{copyAnyAudioTrack ? 'Keep audio' : 'Discard audio'}
</button>
<IoIosHelpCircle size={24} role="button" onClick={toggleHelp} style={{ verticalAlign: 'middle', marginLeft: 5 }} />
@ -1162,14 +1276,6 @@ const App = memo(() => {
</Hammer>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<FaAngleLeft
title="Set cut start to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 3 }}
size={16}
onClick={setCutStart}
role="button"
/>
<i
className="button fa fa-step-backward"
role="button"
@ -1178,6 +1284,14 @@ const App = memo(() => {
onClick={() => seekAbs(0)}
/>
<FaAngleLeft
title="Set cut start to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 3 }}
size={16}
onClick={setCutStart}
role="button"
/>
<div style={{ position: 'relative' }}>
{renderCutTimeInput('start')}
<i
@ -1221,14 +1335,6 @@ const App = memo(() => {
/>
</div>
<i
className="button fa fa-step-forward"
role="button"
tabIndex="0"
title="Jump to end of video"
onClick={() => seekAbs(durationSafe)}
/>
<FaAngleRight
title="Set cut end to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 3 }}
@ -1236,6 +1342,14 @@ const App = memo(() => {
onClick={setCutEnd}
role="button"
/>
<i
className="button fa fa-step-forward"
role="button"
tabIndex="0"
title="Jump to end of video"
onClick={() => seekAbs(durationSafe)}
/>
</div>
</div>
@ -1288,12 +1402,15 @@ const App = memo(() => {
onClick={capture}
/>
<span style={{ background: 'hsl(194, 78%, 47%)', borderRadius: 5, padding: '3px 7px', fontSize: 14 }}>
<FiScissors
<span
style={{ background: 'hsl(194, 78%, 47%)', borderRadius: 5, padding: '3px 7px', fontSize: 14 }}
onClick={cutClick}
title={cutSegments.length > 1 ? 'Export all segments' : 'Export selection'}
role="button"
>
<CutIcon
style={{ verticalAlign: 'middle', marginRight: 3 }}
size={16}
onClick={cutClick}
title={cutSegments.length > 1 ? 'Export all segments' : 'Export selection'}
/>
Export
</span>

Loading…
Cancel
Save