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 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, 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>({}); const [clearBatchFilesAfterConcat, setClearBatchFilesAfterConcat] = useState(false); const [settingsVisible, setSettingsVisible] = useState(false); const [enableReadFileMeta, setEnableReadFileMeta] = useState(false); const [outFileName, setOutFileName] = useState(); const [uniqueSuffix, setUniqueSuffix] = useState(); 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); 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 = {}; 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: (
    {(problemsByFile[path] || []).map((problem) =>
  • {problem}
  • )}
), }); }, [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 ( <>
setEnableReadFileMeta(e.target.checked)} label={t('Check compatibility')} marginLeft={10} marginRight={10} /> {fileFormat && detectedFileFormat ? ( ) : ( )}
{t('Output file name')}: setOutFileName(e.target.value)} />
)} >
{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).')}
{paths.map((path, index) => (
{index + 1} {'. '} {basename(path)} {!allFilesMetaCache[path] && } {problemsByFile[path] && onProblemsByFileClick(path)} title={i18n.t('Mismatches detected')} color="#996A13" style={{ marginLeft: 10 }} />}
))}
{enableReadFileMeta && (!allFilesMeta || Object.values(problemsByFile).length > 0) && ( {t('A mismatch was detected in at least one file. You may proceed, but the resulting file might not be playable.')} )} {!enableReadFileMeta && ( {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.')} )}
setSettingsVisible(false)} title={t('Merge options')} hasCancel={false} confirmLabel={t('Close')}> 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.')}`} /> setPreserveMetadataOnMerge(e.target.checked)} label={t('Preserve original metadata when merging? (slow)')} /> {fileFormat != null && isMov(fileFormat) && setPreserveMovData(e.target.checked)} label={t('Preserve all MP4/MOV metadata?')} />} setSegmentsToChapters(e.target.checked)} label={t('Create chapters from merged segments? (slow)')} /> setAlwaysConcatMultipleFiles(e.target.checked)} label={t('Always open this dialog when opening multiple files')} /> setClearBatchFilesAfterConcat(e.target.checked)} label={t('Clear batch file list after merge')} /> {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.')} ); }); export default ConcatDialog;