kopia lustrzana https://github.com/mifi/lossless-cut
combine batch list and merge list #89
- add button to merge current batch list - show more options in merge dialog - allow preserving metadata when merging also when not using allStreams #1008 - remember concat/batch choice #371pull/1023/head
rodzic
e2175ab825
commit
9ffc781c29
|
@ -243,7 +243,7 @@ module.exports = (app, mainWindow, newVersion) => {
|
|||
label: i18n.t('Tools'),
|
||||
submenu: [
|
||||
{
|
||||
label: i18n.t('Merge files'),
|
||||
label: i18n.t('Merge/concatenate files'),
|
||||
click() {
|
||||
mainWindow.webContents.send('show-merge-dialog', true);
|
||||
},
|
||||
|
|
63
src/App.jsx
63
src/App.jsx
|
@ -3,7 +3,7 @@ import { unstable_batchedUpdates as batchedUpdates } from 'react-dom';
|
|||
import { FaAngleLeft, FaWindowClose, FaTimes } from 'react-icons/fa';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import Lottie from 'react-lottie-player';
|
||||
import { SideSheet, Button, Position, ForkIcon, DisableIcon, Select, ThemeProvider } from 'evergreen-ui';
|
||||
import { SideSheet, Button, Position, ForkIcon, DisableIcon, Select, ThemeProvider, MergeColumnsIcon } from 'evergreen-ui';
|
||||
import { useStateWithHistory } from 'react-use/lib/useStateWithHistory';
|
||||
import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
@ -40,6 +40,7 @@ import ValueTuner from './components/ValueTuner';
|
|||
import VolumeControl from './components/VolumeControl';
|
||||
import SubtitleControl from './components/SubtitleControl';
|
||||
import BatchFile from './components/BatchFile';
|
||||
import ConcatDialog from './components/ConcatDialog';
|
||||
|
||||
import { loadMifiLink } from './mifi';
|
||||
import { primaryColor, controlsBackground, timelineBackground } from './colors';
|
||||
|
@ -63,7 +64,7 @@ import {
|
|||
} from './util';
|
||||
import { formatDuration } from './util/duration';
|
||||
import { adjustRate } from './util/rate-calculator';
|
||||
import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, showMultipleFilesDialog, showOpenAndMergeDialog, openAbout, showEditableJsonDialog } from './dialogs';
|
||||
import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, showMergeFilesOpenDialog, openAbout, showEditableJsonDialog } from './dialogs';
|
||||
import { openSendReportDialog } from './reporting';
|
||||
import { fallbackLng } from './i18n';
|
||||
import { createSegment, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags } from './segments';
|
||||
|
@ -125,6 +126,7 @@ const App = memo(() => {
|
|||
const [mainAudioStream, setMainAudioStream] = useState();
|
||||
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({});
|
||||
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
|
||||
const [concatDialogVisible, setConcatDialogVisible] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [thumbnails, setThumbnails] = useState([]);
|
||||
const [shortestFlag, setShortestFlag] = useState(false);
|
||||
|
@ -146,8 +148,9 @@ const App = memo(() => {
|
|||
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||
const [tunerVisible, setTunerVisible] = useState();
|
||||
const [mifiLink, setMifiLink] = useState();
|
||||
const [alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles] = useState(false);
|
||||
|
||||
// Batch state
|
||||
// Batch state / concat files
|
||||
const [batchFiles, setBatchFiles] = useState([]);
|
||||
|
||||
// Segment related state
|
||||
|
@ -642,9 +645,10 @@ const App = memo(() => {
|
|||
return { cancel: false, newCustomOutDir };
|
||||
}, [customOutDir, setCustomOutDir]);
|
||||
|
||||
const mergeFiles = useCallback(async ({ paths, allStreams, segmentsToChapters: segmentsToChapters2 }) => {
|
||||
const mergeFiles = useCallback(async ({ paths, allStreams }) => {
|
||||
if (workingRef.current) return;
|
||||
try {
|
||||
setConcatDialogVisible(false);
|
||||
setWorking(i18n.t('Merging'));
|
||||
|
||||
const firstPath = paths[0];
|
||||
|
@ -656,7 +660,7 @@ const App = memo(() => {
|
|||
const outDir = getOutDir(customOutDir, firstPath);
|
||||
|
||||
let chapters;
|
||||
if (segmentsToChapters2) {
|
||||
if (segmentsToChapters) {
|
||||
const chapterNames = paths.map((path) => parsePath(path).name);
|
||||
chapters = await createChaptersFromSegments({ segmentPaths: paths, chapterNames });
|
||||
}
|
||||
|
@ -675,7 +679,7 @@ const App = memo(() => {
|
|||
setWorking();
|
||||
setCutProgress();
|
||||
}
|
||||
}, [ensureOutDirAccessible, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, customOutDir, ffmpegMergeFiles, setWorking]);
|
||||
}, [setWorking, ensureOutDirAccessible, customOutDir, segmentsToChapters, ffmpegMergeFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge]);
|
||||
|
||||
const toggleCaptureFormat = useCallback(() => setCaptureFormat(f => (f === 'png' ? 'jpeg' : 'png')), [setCaptureFormat]);
|
||||
|
||||
|
@ -1427,6 +1431,8 @@ const App = memo(() => {
|
|||
const seekAccelerationRef = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (concatDialogVisible) return () => {};
|
||||
|
||||
function onKeyPress() {
|
||||
if (exportConfirmVisible) onExportConfirm();
|
||||
else onExportPress();
|
||||
|
@ -1435,7 +1441,7 @@ const App = memo(() => {
|
|||
const mousetrap = new Mousetrap();
|
||||
mousetrap.bind('e', onKeyPress);
|
||||
return () => mousetrap.reset();
|
||||
}, [exportConfirmVisible, onExportConfirm, onExportPress]);
|
||||
}, [exportConfirmVisible, onExportConfirm, onExportPress, concatDialogVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
function onEscPress() {
|
||||
|
@ -1562,6 +1568,8 @@ const App = memo(() => {
|
|||
}
|
||||
}, [userOpenSingleFile, setWorking, filePath]);
|
||||
|
||||
const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]);
|
||||
|
||||
const batchFileJump = useCallback((direction) => {
|
||||
const pathIndex = batchFiles.findIndex(({ path }) => path === filePath);
|
||||
if (pathIndex === -1) return;
|
||||
|
@ -1572,8 +1580,7 @@ const App = memo(() => {
|
|||
|
||||
const batchLoadFiles = useCallback((paths) => {
|
||||
setBatchFiles(paths.map((path) => ({ path, name: basename(path) })));
|
||||
batchOpenSingleFile(paths[0]);
|
||||
}, [batchOpenSingleFile]);
|
||||
}, []);
|
||||
|
||||
const userOpenFiles = useCallback(async (filePaths) => {
|
||||
if (!filePaths || filePaths.length < 1) return;
|
||||
|
@ -1582,10 +1589,16 @@ const App = memo(() => {
|
|||
console.log(filePaths.join('\n'));
|
||||
|
||||
if (filePaths.length > 1) {
|
||||
showMultipleFilesDialog(filePaths, mergeFiles, batchLoadFiles);
|
||||
batchLoadFiles(filePaths);
|
||||
if (alwaysConcatMultipleFiles) {
|
||||
setConcatDialogVisible(true);
|
||||
} else {
|
||||
batchOpenSingleFile(filePaths[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// filePaths.length is now 1
|
||||
const firstFilePath = filePaths[0];
|
||||
|
||||
|
@ -1642,7 +1655,7 @@ const App = memo(() => {
|
|||
} finally {
|
||||
setWorking();
|
||||
}
|
||||
}, [addStreamSourceFile, checkFileOpened, enableAskForFileOpenAction, isFileOpened, loadEdlFile, mergeFiles, userOpenSingleFile, setWorking, batchLoadFiles]);
|
||||
}, [batchLoadFiles, alwaysConcatMultipleFiles, batchOpenSingleFile, setWorking, isFileOpened, userOpenSingleFile, checkFileOpened, loadEdlFile, enableAskForFileOpenAction, addStreamSourceFile]);
|
||||
|
||||
const userHtml5ifyCurrentFile = useCallback(async () => {
|
||||
if (!filePath) return;
|
||||
|
@ -1693,7 +1706,7 @@ const App = memo(() => {
|
|||
|
||||
// TODO split up?
|
||||
useEffect(() => {
|
||||
if (exportConfirmVisible) return () => {};
|
||||
if (exportConfirmVisible || concatDialogVisible) return () => {};
|
||||
|
||||
const togglePlayNoReset = () => togglePlay();
|
||||
const togglePlayReset = () => togglePlay(true);
|
||||
|
@ -1787,7 +1800,7 @@ const App = memo(() => {
|
|||
}, [
|
||||
addCutSegment, capture, changePlaybackRate, togglePlay, removeCutSegment,
|
||||
setCutEnd, setCutStart, seekRel, seekRelPercent, shortStep, cleanupFiles, jumpSeg,
|
||||
seekClosestKeyframe, zoomRel, toggleComfortZoom, splitCurrentSegment, exportConfirmVisible,
|
||||
seekClosestKeyframe, zoomRel, toggleComfortZoom, splitCurrentSegment, exportConfirmVisible, concatDialogVisible,
|
||||
increaseRotation, jumpCutStart, jumpCutEnd, cutSegmentsHistory, keyboardSeekAccFactor,
|
||||
keyboardNormalSeekSpeed, onLabelSegmentPress, currentSegIndexSafe, batchFileJump, goToTimecode,
|
||||
]);
|
||||
|
@ -1831,11 +1844,12 @@ const App = memo(() => {
|
|||
}, [fileUri, usingPreviewFile, hasVideo, hasAudio, html5ifyAndLoadWithPreferences, customOutDir, filePath, setWorking]);
|
||||
|
||||
useEffect(() => {
|
||||
function showOpenAndMergeDialog2() {
|
||||
showOpenAndMergeDialog({
|
||||
defaultPath: outputDir,
|
||||
onMergeClick: mergeFiles,
|
||||
});
|
||||
async function showOpenAndMergeDialog() {
|
||||
const files = await showMergeFilesOpenDialog(outputDir);
|
||||
if (files) {
|
||||
batchLoadFiles(files);
|
||||
setConcatDialogVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function setStartOffset() {
|
||||
|
@ -1961,7 +1975,7 @@ const App = memo(() => {
|
|||
electron.ipcRenderer.on('close-file', closeFile2);
|
||||
electron.ipcRenderer.on('close-batch-files', closeBatch2);
|
||||
electron.ipcRenderer.on('html5ify', userHtml5ifyCurrentFile);
|
||||
electron.ipcRenderer.on('show-merge-dialog', showOpenAndMergeDialog2);
|
||||
electron.ipcRenderer.on('show-merge-dialog', showOpenAndMergeDialog);
|
||||
electron.ipcRenderer.on('set-start-offset', setStartOffset);
|
||||
electron.ipcRenderer.on('extract-all-streams', extractAllStreams);
|
||||
electron.ipcRenderer.on('showStreamsSelector', showStreamsSelector);
|
||||
|
@ -1985,7 +1999,7 @@ const App = memo(() => {
|
|||
electron.ipcRenderer.removeListener('close-file', closeFile2);
|
||||
electron.ipcRenderer.removeListener('close-batch-files', closeBatch2);
|
||||
electron.ipcRenderer.removeListener('html5ify', userHtml5ifyCurrentFile);
|
||||
electron.ipcRenderer.removeListener('show-merge-dialog', showOpenAndMergeDialog2);
|
||||
electron.ipcRenderer.removeListener('show-merge-dialog', showOpenAndMergeDialog);
|
||||
electron.ipcRenderer.removeListener('set-start-offset', setStartOffset);
|
||||
electron.ipcRenderer.removeListener('extract-all-streams', extractAllStreams);
|
||||
electron.ipcRenderer.removeListener('showStreamsSelector', showStreamsSelector);
|
||||
|
@ -2005,7 +2019,7 @@ const App = memo(() => {
|
|||
electron.ipcRenderer.removeListener('reorderSegsByStartTime', reorderSegsByStartTime);
|
||||
};
|
||||
}, [
|
||||
mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, userHtml5ifyCurrentFile,
|
||||
outputDir, filePath, customOutDir, startTimeOffset, userHtml5ifyCurrentFile, batchLoadFiles,
|
||||
extractAllStreams, userOpenFiles, openSendReportDialogWithState, setWorking,
|
||||
loadEdlFile, cutSegments, apparentCutSegments, edlFilePath, toggleHelp, toggleSettings, ensureOutDirAccessible, html5ifyAndLoad, html5ify,
|
||||
loadCutSegments, duration, checkFileOpened, loadMedia, fileFormat, reorderSegsByStartTime, closeFileWithConfirm, closeBatch, clearSegments, clearSegCounter, fixInvalidDuration, invertAllCutSegments, getFrameCount,
|
||||
|
@ -2344,6 +2358,11 @@ const App = memo(() => {
|
|||
>
|
||||
<div style={{ background: controlsBackground, fontSize: 14, paddingBottom: 7, paddingTop: 3, paddingLeft: 10, paddingRight: 5, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{t('Batch file list')}
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<MergeColumnsIcon role="button" title={t('Merge/concatenate files')} color="white" style={{ marginRight: 10, cursor: 'pointer' }} onClick={() => setConcatDialogVisible(true)} />
|
||||
|
||||
<FaTimes size={18} role="button" style={{ cursor: 'pointer', color: 'white' }} onClick={() => closeBatch()} title={t('Close batch')} />
|
||||
</div>
|
||||
|
||||
|
@ -2532,6 +2551,8 @@ const App = memo(() => {
|
|||
renderSettings={renderSettings}
|
||||
/>
|
||||
|
||||
<ConcatDialog isShown={batchFiles.length > 0 && concatDialogVisible} onHide={() => setConcatDialogVisible(false)} initialPaths={batchFilePaths} onConcat={mergeFiles} segmentsToChapters={segmentsToChapters} setSegmentsToChapters={setSegmentsToChapters} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} preserveMetadataOnMerge={preserveMetadataOnMerge} setPreserveMetadataOnMerge={setPreserveMetadataOnMerge} preserveMovData={preserveMovData} setPreserveMovData={setPreserveMovData} />
|
||||
|
||||
{tunerVisible && renderTuner(tunerVisible)}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
import React, { memo, useState, useCallback, useEffect } from 'react';
|
||||
import { sortableContainer, sortableElement } from 'react-sortable-hoc';
|
||||
import { Pane, Checkbox, SortAlphabeticalIcon, SortAlphabeticalDescIcon, IconButton } from 'evergreen-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import arrayMove from 'array-move';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
|
||||
const { basename } = window.require('path');
|
||||
|
||||
const rowStyle = {
|
||||
padding: '3px 10px', fontSize: 14, margin: '7px 0', overflowY: 'auto', whiteSpace: 'nowrap', cursor: 'grab',
|
||||
};
|
||||
|
||||
const SortableItem = sortableElement(({ value, sortIndex }) => (
|
||||
<Pane elevation={1} style={rowStyle} title={value}>
|
||||
{sortIndex + 1}
|
||||
{'. '}
|
||||
{basename(value)}
|
||||
</Pane>
|
||||
));
|
||||
|
||||
const SortableContainer = sortableContainer(({ items }) => (
|
||||
<div style={{ padding: '0 3px' }}>
|
||||
{items.map((value, index) => (
|
||||
<SortableItem key={value} index={index} sortIndex={index} value={value} />
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
|
||||
const SortableFiles = memo(({
|
||||
items: itemsProp, onChange, helperContainer, onAllStreamsChange, onSegmentsToChaptersChange,
|
||||
}) => {
|
||||
const [items, setItems] = useState(itemsProp);
|
||||
const [allStreams, setAllStreams] = useState(false);
|
||||
const [segmentsToChapters, setSegmentsToChapters] = useState(false);
|
||||
const [sortDesc, setSortDesc] = useState();
|
||||
|
||||
useEffect(() => onAllStreamsChange(allStreams), [allStreams, onAllStreamsChange]);
|
||||
useEffect(() => onSegmentsToChaptersChange(segmentsToChapters), [segmentsToChapters, onSegmentsToChaptersChange]);
|
||||
useEffect(() => onChange(items), [items, onChange]);
|
||||
|
||||
const onSortEnd = useCallback(({ oldIndex, newIndex }) => {
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(newItems);
|
||||
}, [items]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onSortClick = useCallback(() => {
|
||||
const newSortDesc = sortDesc == null ? false : !sortDesc;
|
||||
setItems(orderBy(items, undefined, [newSortDesc ? 'desc' : 'asc']));
|
||||
setSortDesc(newSortDesc);
|
||||
}, [items, sortDesc]);
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, marginBottom: 10 }}>{t('This dialog can be used to concatenate files in series, e.g. one after the other:\n[file1][file2][file3]\nIt can NOT be used for merging tracks in parallell (like adding an audio track to a video).\nMake sure all files are of the exact same codecs & codec parameters (fps, resolution etc).\n\nDrag and drop to change the order of your files here:')}</div>
|
||||
|
||||
<SortableContainer
|
||||
items={items}
|
||||
onSortEnd={onSortEnd}
|
||||
helperContainer={helperContainer}
|
||||
getContainer={() => helperContainer().parentNode}
|
||||
helperClass="dragging-helper-class"
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Checkbox checked={allStreams} onChange={e => setAllStreams(e.target.checked)} label={`${t('Include all tracks?')} ${t('If this is checked, all audio/video/subtitle/data tracks will be included. This may not always work for all file types. If not checked, only default streams will be included.')}`} />
|
||||
|
||||
<Checkbox checked={segmentsToChapters} onChange={e => setSegmentsToChapters(e.target.checked)} label={t('Create chapters from merged segments? (slow)')} />
|
||||
|
||||
<div>
|
||||
<IconButton icon={sortDesc ? SortAlphabeticalDescIcon : SortAlphabeticalIcon} onClick={onSortClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default SortableFiles;
|
|
@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { FaAngleRight, FaFile } from 'react-icons/fa';
|
||||
|
||||
import useContextMenu from '../hooks/useContextMenu';
|
||||
import { primaryColor } from '../colors';
|
||||
import { primaryTextColor } from '../colors';
|
||||
|
||||
const BatchFile = memo(({ path, filePath, name, onOpen, onDelete }) => {
|
||||
const ref = useRef();
|
||||
|
@ -18,7 +18,9 @@ const BatchFile = memo(({ path, filePath, name, onOpen, onDelete }) => {
|
|||
|
||||
return (
|
||||
<div ref={ref} role="button" style={{ background: isCurrent ? 'rgba(255,255,255,0.15)' : undefined, fontSize: 13, padding: '1px 3px', cursor: 'pointer', display: 'flex', alignItems: 'center', minHeight: 30, alignContent: 'flex-start' }} title={path} onClick={() => onOpen(path)}>
|
||||
<FaFile size={14} style={{ color: primaryColor, marginLeft: 3, marginRight: 4, flexShrink: 0 }} />
|
||||
<div style={{ flexBasis: 5, flexShrink: 1 }} />
|
||||
<FaFile size={14} style={{ color: primaryTextColor, flexShrink: 0 }} />
|
||||
<div style={{ flexBasis: 10, flexShrink: 1 }} />
|
||||
<div style={{ wordBreak: 'break-all' }}>{name}</div>
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
{isCurrent && <FaAngleRight size={14} style={{ color: 'white', marginRight: -3, flexShrink: 0 }} />}
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
import React, { memo, useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Dialog, Pane, Checkbox, SortAlphabeticalIcon, SortAlphabeticalDescIcon, Button, Paragraph } from 'evergreen-ui';
|
||||
import { sortableContainer, sortableElement } from 'react-sortable-hoc';
|
||||
import arrayMove from 'array-move';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
|
||||
const { basename } = window.require('path');
|
||||
|
||||
const containerStyle = { color: 'black' };
|
||||
|
||||
const rowStyle = {
|
||||
color: 'black', padding: '3px 10px', fontSize: 14, margin: '7px 0', overflowY: 'auto', whiteSpace: 'nowrap', cursor: 'grab',
|
||||
};
|
||||
|
||||
const SortableItem = sortableElement(({ value, sortIndex }) => (
|
||||
<Pane elevation={1} style={rowStyle} title={value}>
|
||||
{sortIndex + 1}
|
||||
{'. '}
|
||||
{basename(value)}
|
||||
</Pane>
|
||||
));
|
||||
|
||||
const SortableContainer = sortableContainer(({ items }) => (
|
||||
<div style={{ padding: '0 3px' }}>
|
||||
{items.map((value, index) => (
|
||||
<SortableItem key={value} index={index} sortIndex={index} value={value} />
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
|
||||
const ConcatDialog = memo(({
|
||||
isShown, onHide, initialPaths, onConcat,
|
||||
segmentsToChapters, setSegmentsToChapters,
|
||||
alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles,
|
||||
preserveMetadataOnMerge, setPreserveMetadataOnMerge,
|
||||
preserveMovData, setPreserveMovData,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [paths, setPaths] = useState(initialPaths);
|
||||
const [allStreams, setAllStreams] = useState(false);
|
||||
const [sortDesc, setSortDesc] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
setPaths(initialPaths);
|
||||
}, [initialPaths]);
|
||||
|
||||
const onSortEnd = useCallback(({ oldIndex, newIndex }) => {
|
||||
const newPaths = arrayMove(paths, oldIndex, newIndex);
|
||||
setPaths(newPaths);
|
||||
}, [paths]);
|
||||
|
||||
const onSortClick = useCallback(() => {
|
||||
const newSortDesc = sortDesc == null ? false : !sortDesc;
|
||||
setPaths(orderBy(paths, undefined, [newSortDesc ? 'desc' : 'asc']));
|
||||
setSortDesc(newSortDesc);
|
||||
}, [paths, sortDesc]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={t('Merge/concatenate files')}
|
||||
isShown={isShown}
|
||||
confirmLabel={t('Merge!')}
|
||||
cancelLabel={t('Cancel')}
|
||||
onCloseComplete={onHide}
|
||||
onConfirm={() => onConcat({ paths, allStreams })}
|
||||
topOffset="3vh"
|
||||
>
|
||||
<div style={containerStyle}>
|
||||
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, marginBottom: 10 }}>
|
||||
{t('This dialog can be used to concatenate files in series, e.g. one after the other:\n[file1][file2][file3]\nIt can NOT be used for merging tracks in parallell (like adding an audio track to a video).\nMake sure all files are of the exact same codecs & codec parameters (fps, resolution etc).\n\nDrag and drop to change the order of your files here:')}
|
||||
</div>
|
||||
|
||||
<SortableContainer
|
||||
items={paths}
|
||||
onSortEnd={onSortEnd}
|
||||
helperClass="dragging-helper-class"
|
||||
/>
|
||||
|
||||
<Button iconBefore={sortDesc ? SortAlphabeticalDescIcon : SortAlphabeticalIcon} onClick={onSortClick}>{t('Sort items')}</Button>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Checkbox checked={allStreams} onChange={(e) => setAllStreams(e.target.checked)} label={`${t('Include all tracks?')} ${t('If this is checked, all audio/video/subtitle/data tracks will be included. This may not always work for all file types. If not checked, only default streams will be included.')}`} />
|
||||
|
||||
<Checkbox checked={preserveMetadataOnMerge} onChange={(e) => setPreserveMetadataOnMerge(e.target.checked)} label={t('Preserve original metadata when merging? (slow)')} />
|
||||
|
||||
<Checkbox checked={preserveMovData} onChange={(e) => setPreserveMovData(e.target.checked)} label={t('Preserve all MP4/MOV metadata?')} />
|
||||
|
||||
<Checkbox checked={segmentsToChapters} onChange={(e) => setSegmentsToChapters(e.target.checked)} label={t('Create chapters from merged segments? (slow)')} />
|
||||
|
||||
<Paragraph>{t('Note that also other settings from the normal export dialog apply to this merge function. For more information about all options, see the export dialog.')}</Paragraph>
|
||||
|
||||
<Checkbox checked={alwaysConcatMultipleFiles} onChange={(e) => setAlwaysConcatMultipleFiles(e.target.checked)} label={t('Always open this dialog when opening multiple files')} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
export default ConcatDialog;
|
|
@ -13,8 +13,6 @@ import { parseYouTube } from './edlFormats';
|
|||
import CopyClipboardButton from './components/CopyClipboardButton';
|
||||
import { errorToast } from './util';
|
||||
|
||||
import SortableFiles from './SortableFiles';
|
||||
|
||||
const electron = window.require('electron'); // eslint-disable-line
|
||||
|
||||
const { dialog, app } = electron.remote;
|
||||
|
@ -352,55 +350,7 @@ export function openAbout() {
|
|||
});
|
||||
}
|
||||
|
||||
export async function showMergeDialog(paths, onMergeClick) {
|
||||
let swalElem;
|
||||
let outPaths = paths;
|
||||
let allStreams = false;
|
||||
let segmentsToChapters = false;
|
||||
const { dismiss } = await ReactSwal.fire({
|
||||
width: '90%',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: i18n.t('Merge!'),
|
||||
cancelButtonText: i18n.t('Cancel'),
|
||||
willOpen: (el) => { swalElem = el; },
|
||||
title: i18n.t('Merge/concatenate files'),
|
||||
html: (<SortableFiles
|
||||
items={outPaths}
|
||||
onChange={(val) => { outPaths = val; }}
|
||||
onAllStreamsChange={(val) => { allStreams = val; }}
|
||||
onSegmentsToChaptersChange={(val) => { segmentsToChapters = val; }}
|
||||
helperContainer={() => swalElem}
|
||||
/>),
|
||||
});
|
||||
|
||||
if (!dismiss) {
|
||||
await onMergeClick({ paths: outPaths, allStreams, segmentsToChapters });
|
||||
}
|
||||
}
|
||||
|
||||
export async function showMultipleFilesDialog(paths, onMergeClick, onBatchLoadFilesClick) {
|
||||
const { isConfirmed, isDenied } = await Swal.fire({
|
||||
showCloseButton: true,
|
||||
showDenyButton: true,
|
||||
denyButtonAriaLabel: '',
|
||||
denyButtonColor: '#7367f0',
|
||||
denyButtonText: i18n.t('Batch files'),
|
||||
confirmButtonText: i18n.t('Merge/concatenate files'),
|
||||
title: i18n.t('Multiple files'),
|
||||
text: i18n.t('Do you want to merge/concatenate the files or load them for batch processing?'),
|
||||
});
|
||||
|
||||
if (isDenied) {
|
||||
await onBatchLoadFilesClick(paths);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isConfirmed) {
|
||||
await showMergeDialog(paths, onMergeClick);
|
||||
}
|
||||
}
|
||||
|
||||
export async function showOpenAndMergeDialog({ defaultPath, onMergeClick }) {
|
||||
export async function showMergeFilesOpenDialog(defaultPath) {
|
||||
const title = i18n.t('Please select files to be merged');
|
||||
const message = i18n.t('Please select files to be merged. The files need to be of the exact same format and codecs');
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
|
@ -409,14 +359,14 @@ export async function showOpenAndMergeDialog({ defaultPath, onMergeClick }) {
|
|||
properties: ['openFile', 'multiSelections'],
|
||||
message,
|
||||
});
|
||||
if (canceled || !filePaths) return;
|
||||
if (canceled || !filePaths) return undefined;
|
||||
|
||||
if (filePaths.length < 2) {
|
||||
errorToast(i18n.t('More than one file must be selected'));
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
showMergeDialog(filePaths, onMergeClick);
|
||||
return filePaths;
|
||||
}
|
||||
|
||||
export async function showEditableJsonDialog({ text, title, inputLabel, inputValue, inputValidator }) {
|
||||
|
|
|
@ -227,9 +227,16 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
|
||||
const ffmetadataPath = await writeChaptersFfmetadata(outDir, chapters);
|
||||
|
||||
const shouldMapMetadata = preserveMetadataOnMerge && allStreams;
|
||||
|
||||
try {
|
||||
let map;
|
||||
if (allStreams) map = ['-map', '0'];
|
||||
// If preserveMetadataOnMerge option is enabled, we need to explicitly map even if allStreams=false.
|
||||
// We cannot use the ffmpeg's automatic stream selection or else ffmpeg might use the metadata source input (index 1)
|
||||
// instead of the concat input (index 0)
|
||||
// https://ffmpeg.org/ffmpeg.html#Automatic-stream-selection
|
||||
else if (preserveMetadataOnMerge) map = ['-map', 'v:0?', '-map', 'a:0?', '-map', 's:0?'];
|
||||
else map = []; // ffmpeg default mapping
|
||||
|
||||
// Keep this similar to cutSingle()
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
|
@ -240,20 +247,20 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
|||
'-f', 'concat', '-safe', '0', '-protocol_whitelist', 'file,pipe',
|
||||
'-i', '-',
|
||||
|
||||
// Add the first file for using its metadata. Can only do this if allStreams (-map 0) is set, or else ffmpeg might output this input instead of the concat
|
||||
...(shouldMapMetadata ? ['-i', paths[0]] : []),
|
||||
// Add the first file (we will get metadata from this input)
|
||||
...(preserveMetadataOnMerge ? ['-i', paths[0]] : []),
|
||||
|
||||
// Chapters?
|
||||
...(ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []),
|
||||
|
||||
'-c', 'copy',
|
||||
|
||||
...(allStreams ? ['-map', '0'] : []),
|
||||
...map,
|
||||
|
||||
// -map_metadata 0 with concat demuxer doesn't transfer metadata from the concat'ed files when merging.
|
||||
// -map_metadata 0 with concat demuxer doesn't transfer metadata from the concat'ed file input (index 0) when merging.
|
||||
// So we use the first file file (index 1) for metadata
|
||||
// Can only do this if allStreams (-map 0) is set
|
||||
...(shouldMapMetadata ? ['-map_metadata', '1'] : []),
|
||||
...(preserveMetadataOnMerge ? ['-map_metadata', '1'] : []),
|
||||
|
||||
...getMovFlags({ preserveMovData, movFastStart }),
|
||||
...getMatroskaFlags(),
|
||||
|
|
|
@ -48,6 +48,8 @@ kbd {
|
|||
|
||||
.dragging-helper-class {
|
||||
color: rgba(0,0,0,0.5);
|
||||
/* https://github.com/clauderic/react-sortable-hoc/issues/803 */
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
|
|
Ładowanie…
Reference in New Issue