diff --git a/src/StreamsSelector.jsx b/src/StreamsSelector.jsx index 17bef3e1..bec5cc98 100644 --- a/src/StreamsSelector.jsx +++ b/src/StreamsSelector.jsx @@ -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 ( + onToggle && onToggle(stream.index)} + > + + {stream.index} + {stream.codec_type} + {stream.codec_tag !== '0x0000' && stream.codec_tag_string} + {stream.codec_name} + {!Number.isNaN(duration) && `${formatDuration({ seconds: duration })}`} + {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(1)}fps`} + + ); +}); + 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 (
-

Click to select which tracks to keep:

+

Click to select which tracks to keep when exporting:

- +
- {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 ( - - - - - - - - - - + {existingStreams.map((stream) => ( + toggleCopyStreamId(mainFilePath, streamId)} + /> + ))} + + {Object.entries(externalFiles).map(([path, { streams }]) => ( + + + - ); - })} + + {streams.map((stream) => ( + toggleCopyStreamId(path, streamId)} + /> + ))} + + ))}
@@ -32,49 +78,45 @@ const StreamsSelector = memo(({
{stream.index}{stream.codec_type}{stream.codec_tag !== '0x0000' && stream.codec_tag_string}{stream.codec_name}{!Number.isNaN(duration) && `${formatDuration({ seconds: duration })}`}{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(1)}fps`}
+ {path} removeFile(path)} /> +
-
- Export each track as individual files +
+ Include tracks from other file
+ + {Object.keys(externalFiles).length === 0 && ( +
+ Export each track as individual files +
+ )}
); }); diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 0ec92f4e..5991de4f 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -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, }; diff --git a/src/menu.js b/src/menu.js index 080c4fb2..4276fa09 100644 --- a/src/menu.js +++ b/src/menu.js @@ -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); }, diff --git a/src/outFormats.js b/src/outFormats.js new file mode 100644 index 00000000..2866ff91 --- /dev/null +++ b/src/outFormats.js @@ -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', +}; diff --git a/src/renderer.jsx b/src/renderer.jsx index 4f260583..dd4ee98f 100644 --- a/src/renderer.jsx +++ b/src/renderer.jsx @@ -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]) => ( + + )); + } function renderOutFmt({ width } = {}) { return ( - setFileFormat(e.target.value))}> + + {detectedFileFormat && ( )} - {selectableFormats.map(f => )} + + + {renderFormatOptions(commonFormatsMap)} + + + {renderFormatOptions(otherFormatsMap)} ); } @@ -922,14 +1018,14 @@ const App = memo(() => { - Delete audio? + Discard audio? @@ -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 (
- - )} + setStreamsSelectorShown(false)} > - - + + +
+ + {renderOutFmt({ width: 60 })} @@ -1162,14 +1276,6 @@ const App = memo(() => {
- - { onClick={() => seekAbs(0)} /> + +
{renderCutTimeInput('start')} { />
- seekAbs(durationSafe)} - /> - { onClick={setCutEnd} role="button" /> + + seekAbs(durationSafe)} + />
@@ -1288,12 +1402,15 @@ const App = memo(() => { onClick={capture} /> - - 1 ? 'Export all segments' : 'Export selection'} + role="button" + > + 1 ? 'Export all segments' : 'Export selection'} /> Export