kopia lustrzana https://github.com/mifi/lossless-cut
244 wiersze
12 KiB
TypeScript
244 wiersze
12 KiB
TypeScript
import { memo, useState, useCallback, useEffect, useMemo, CSSProperties } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { TextInput, IconButton, Alert, Checkbox, Dialog, Button, Paragraph, CogIcon } from 'evergreen-ui';
|
|
import { AiOutlineMergeCells } from 'react-icons/ai';
|
|
import { FaQuestionCircle, FaExclamationTriangle } from 'react-icons/fa';
|
|
import i18n from 'i18next';
|
|
import withReactContent from 'sweetalert2-react-content';
|
|
import invariant from 'tiny-invariant';
|
|
|
|
import Swal from '../swal';
|
|
import { readFileMeta, getSmarterOutFormat } from '../ffmpeg';
|
|
import useFileFormatState from '../hooks/useFileFormatState';
|
|
import OutputFormatSelect from './OutputFormatSelect';
|
|
import useUserSettings from '../hooks/useUserSettings';
|
|
import { isMov } from '../util/streams';
|
|
import { getOutFileExtension, getSuffixedFileName } from '../util';
|
|
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../../ffprobe';
|
|
|
|
const { basename } = window.require('path');
|
|
|
|
const ReactSwal = withReactContent(Swal);
|
|
|
|
const containerStyle: CSSProperties = { color: 'black' };
|
|
|
|
const rowStyle: CSSProperties = {
|
|
color: 'black', fontSize: 14, margin: '4px 0px', overflowY: 'auto', whiteSpace: 'nowrap',
|
|
};
|
|
|
|
const ConcatDialog = memo(({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles }: {
|
|
isShown: boolean, onHide: () => void, paths: string[], onConcat: (a: { paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], outFileName: string, fileFormat: string, clearBatchFilesAfterConcat: boolean }) => Promise<void>, alwaysConcatMultipleFiles: boolean, setAlwaysConcatMultipleFiles: (a: boolean) => void,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const { preserveMovData, setPreserveMovData, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge } = useUserSettings();
|
|
|
|
const [includeAllStreams, setIncludeAllStreams] = useState(false);
|
|
const [fileMeta, setFileMeta] = useState<{ format: FFprobeFormat, streams: FFprobeStream[], chapters: FFprobeChapter[] }>();
|
|
const [allFilesMetaCache, setAllFilesMetaCache] = useState<Record<string, {format: FFprobeFormat, streams: FFprobeStream[], chapters: FFprobeChapter[] }>>({});
|
|
const [clearBatchFilesAfterConcat, setClearBatchFilesAfterConcat] = useState(false);
|
|
const [settingsVisible, setSettingsVisible] = useState(false);
|
|
const [enableReadFileMeta, setEnableReadFileMeta] = useState(false);
|
|
const [outFileName, setOutFileName] = useState<string>();
|
|
const [uniqueSuffix, setUniqueSuffix] = useState<number>();
|
|
|
|
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
|
|
|
const firstPath = useMemo(() => {
|
|
if (paths.length === 0) return undefined;
|
|
return paths[0];
|
|
}, [paths]);
|
|
|
|
useEffect(() => {
|
|
if (!isShown) return undefined;
|
|
|
|
let aborted = false;
|
|
|
|
(async () => {
|
|
setFileMeta(undefined);
|
|
setFileFormat(undefined);
|
|
setDetectedFileFormat(undefined);
|
|
setOutFileName(undefined);
|
|
invariant(firstPath != null);
|
|
const fileMetaNew = await readFileMeta(firstPath);
|
|
const fileFormatNew = await getSmarterOutFormat({ filePath: firstPath, fileMeta: fileMetaNew });
|
|
if (aborted) return;
|
|
setFileMeta(fileMetaNew);
|
|
setFileFormat(fileFormatNew);
|
|
setDetectedFileFormat(fileFormatNew);
|
|
setUniqueSuffix(Date.now());
|
|
})().catch(console.error);
|
|
|
|
return () => {
|
|
aborted = true;
|
|
};
|
|
}, [firstPath, isShown, setDetectedFileFormat, setFileFormat]);
|
|
|
|
useEffect(() => {
|
|
if (fileFormat == null || firstPath == null) {
|
|
setOutFileName(undefined);
|
|
return;
|
|
}
|
|
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath: firstPath });
|
|
setOutFileName((existingOutputName) => {
|
|
if (existingOutputName == null) return getSuffixedFileName(firstPath, `merged-${uniqueSuffix}${ext}`);
|
|
return existingOutputName.replace(/(\.[^.]*)?$/, ext); // make sure the last (optional) .* is replaced by .ext`
|
|
});
|
|
}, [fileFormat, firstPath, isCustomFormatSelected, uniqueSuffix]);
|
|
|
|
const allFilesMeta = useMemo(() => {
|
|
if (paths.length === 0) return undefined;
|
|
const filtered = paths.flatMap((path) => (allFilesMetaCache[path] ? [[path, allFilesMetaCache[path]!] as const] : []));
|
|
return filtered.length === paths.length ? filtered : undefined;
|
|
}, [allFilesMetaCache, paths]);
|
|
|
|
const isOutFileNameValid = outFileName != null && outFileName.length > 0;
|
|
|
|
const problemsByFile = useMemo(() => {
|
|
if (!allFilesMeta) return {};
|
|
const allFilesMetaExceptFirstFile = allFilesMeta.slice(1);
|
|
const [, firstFileMeta] = allFilesMeta[0]!;
|
|
const errors: Record<string, string[]> = {};
|
|
|
|
function addError(path: string, error: string) {
|
|
if (!errors[path]) errors[path] = [];
|
|
errors[path]!.push(error);
|
|
}
|
|
|
|
allFilesMetaExceptFirstFile.forEach(([path, { streams }]) => {
|
|
streams.forEach((stream, i) => {
|
|
const referenceStream = firstFileMeta.streams[i];
|
|
if (!referenceStream) {
|
|
addError(path, i18n.t('Extraneous track {{index}}', { index: stream.index + 1 }));
|
|
return;
|
|
}
|
|
// check all these parameters
|
|
['codec_name', 'width', 'height', 'fps', 'pix_fmt', 'level', 'profile', 'sample_fmt', 'r_frame_rate', 'time_base'].forEach((key) => {
|
|
const val = stream[key];
|
|
const referenceVal = referenceStream[key];
|
|
if (val !== referenceVal) {
|
|
addError(path, i18n.t('Track {{index}} mismatch: {{key1}} {{value1}} != {{value2}}', { index: stream.index + 1, key1: key, value1: val || 'none', value2: referenceVal || 'none' }));
|
|
}
|
|
});
|
|
});
|
|
});
|
|
return errors;
|
|
}, [allFilesMeta]);
|
|
|
|
const onProblemsByFileClick = useCallback((path: string) => {
|
|
ReactSwal.fire({
|
|
title: i18n.t('Mismatches detected'),
|
|
html: (
|
|
<ul style={{ margin: '10px 0', textAlign: 'left' }}>
|
|
{(problemsByFile[path] || []).map((problem) => <li key={problem}>{problem}</li>)}
|
|
</ul>
|
|
),
|
|
});
|
|
}, [problemsByFile]);
|
|
|
|
useEffect(() => {
|
|
if (!isShown || !enableReadFileMeta) return undefined;
|
|
|
|
let aborted = false;
|
|
|
|
(async () => {
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for (const path of paths) {
|
|
if (aborted) return;
|
|
if (!allFilesMetaCache[path]) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const fileMetaNew = await readFileMeta(path);
|
|
setAllFilesMetaCache((existing) => ({ ...existing, [path]: fileMetaNew }));
|
|
}
|
|
}
|
|
})().catch(console.error);
|
|
|
|
return () => {
|
|
aborted = true;
|
|
};
|
|
}, [allFilesMetaCache, enableReadFileMeta, isShown, paths]);
|
|
|
|
const onOutputFormatUserChange = useCallback((newFormat) => setFileFormat(newFormat), [setFileFormat]);
|
|
|
|
const onConcatClick = useCallback(() => {
|
|
if (outFileName == null) throw new Error();
|
|
if (fileFormat == null) throw new Error();
|
|
onConcat({ paths, includeAllStreams, streams: fileMeta!.streams, outFileName, fileFormat, clearBatchFilesAfterConcat });
|
|
}, [clearBatchFilesAfterConcat, fileFormat, fileMeta, includeAllStreams, onConcat, outFileName, paths]);
|
|
|
|
return (
|
|
<>
|
|
<Dialog
|
|
title={t('Merge/concatenate files')}
|
|
shouldCloseOnOverlayClick={false}
|
|
isShown={isShown}
|
|
onCloseComplete={onHide}
|
|
topOffset="3vh"
|
|
width="90vw"
|
|
footer={(
|
|
<>
|
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
|
<Checkbox checked={enableReadFileMeta} onChange={(e) => setEnableReadFileMeta(e.target.checked)} label={t('Check compatibility')} marginLeft={10} marginRight={10} />
|
|
<Button iconBefore={CogIcon} onClick={() => setSettingsVisible(true)}>{t('Options')}</Button>
|
|
{fileFormat && detectedFileFormat ? (
|
|
<OutputFormatSelect style={{ height: 30, maxWidth: 180 }} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />
|
|
) : (
|
|
<Button disabled isLoading>{t('Loading')}</Button>
|
|
)}
|
|
<Button iconBefore={<AiOutlineMergeCells />} isLoading={detectedFileFormat == null} disabled={!isOutFileNameValid} appearance="primary" onClick={onConcatClick}>{t('Merge!')}</Button>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
|
<Paragraph marginRight=".5em">{t('Output file name')}:</Paragraph>
|
|
<TextInput value={outFileName || ''} onChange={(e) => setOutFileName(e.target.value)} />
|
|
</div>
|
|
</>
|
|
)}
|
|
>
|
|
<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).')}
|
|
</div>
|
|
|
|
<div>
|
|
{paths.map((path, index) => (
|
|
<div key={path} style={rowStyle} title={path}>
|
|
<div>
|
|
{index + 1}
|
|
{'. '}
|
|
<span style={{ color: 'rgba(0,0,0,0.7)' }}>{basename(path)}</span>
|
|
{!allFilesMetaCache[path] && <FaQuestionCircle color="#996A13" style={{ marginLeft: 10 }} />}
|
|
{problemsByFile[path] && <IconButton appearance="minimal" icon={FaExclamationTriangle} onClick={() => onProblemsByFileClick(path)} title={i18n.t('Mismatches detected')} color="#996A13" style={{ marginLeft: 10 }} />}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{enableReadFileMeta && (!allFilesMeta || Object.values(problemsByFile).length > 0) && (
|
|
<Alert intent="warning">{t('A mismatch was detected in at least one file. You may proceed, but the resulting file might not be playable.')}</Alert>
|
|
)}
|
|
{!enableReadFileMeta && (
|
|
<Alert intent="warning">{t('File compatibility check is not enabled, so the merge operation might not produce a valid output. Enable "Check compatibility" below to check file compatibility before merging.')}</Alert>
|
|
)}
|
|
</Dialog>
|
|
|
|
<Dialog isShown={settingsVisible} onCloseComplete={() => setSettingsVisible(false)} title={t('Merge options')} hasCancel={false} confirmLabel={t('Close')}>
|
|
<Checkbox checked={includeAllStreams} onChange={(e) => setIncludeAllStreams(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)')} />
|
|
|
|
{fileFormat != null && isMov(fileFormat) && <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)')} />
|
|
|
|
<Checkbox checked={alwaysConcatMultipleFiles} onChange={(e) => setAlwaysConcatMultipleFiles(e.target.checked)} label={t('Always open this dialog when opening multiple files')} />
|
|
|
|
<Checkbox checked={clearBatchFilesAfterConcat} onChange={(e) => setClearBatchFilesAfterConcat(e.target.checked)} label={t('Clear batch file list after merge')} />
|
|
|
|
<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>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export default ConcatDialog;
|