1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 10:22:31 +01:00

improvements

- improve concat/merge success dialog and error dialog
- improve error classification (ENOENT)
This commit is contained in:
Mikael Finstad 2023-02-03 17:27:11 +08:00
parent f7047160a1
commit 97f7e2c88a
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
6 changed files with 136 additions and 49 deletions

View File

@ -65,14 +65,14 @@ import {
checkDirWriteAccess, dirExists, isMasBuild, isStoreBuild, dragPreventer,
filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate,
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
deleteFiles, isOutOfSpaceError, shuffleArray, getNumDigits,
deleteFiles, isOutOfSpaceError, shuffleArray, getNumDigits, isExecaFailure,
} from './util';
import { formatDuration } from './util/duration';
import { adjustRate } from './util/rate-calculator';
import { showParametersDialog } from './dialogs/parameters';
import { askExtractFramesAsImages } from './dialogs/extractFrames';
import { askForHtml5ifySpeed } from './dialogs/html5ify';
import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, promptTimeOffset, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog, askForShiftSegments, selectSegmentsByLabelDialog, showRefuseToOverwrite, openDirToast, openCutFinishedToast } from './dialogs';
import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, promptTimeOffset, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog, askForShiftSegments, selectSegmentsByLabelDialog, showRefuseToOverwrite, openDirToast, openCutFinishedToast, openConcatFinishedToast } from './dialogs';
import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n';
import { createSegment, getCleanCutSegments, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap, combineOverlappingSegments as combineOverlappingSegments2, isDurationValid } from './segments';
@ -1105,6 +1105,48 @@ const App = memo(() => {
});
}, []);
const commonSettings = useMemo(() => ({
ffmpegExperimental,
preserveMovData,
movFastStart,
preserveMetadataOnMerge,
}), [ffmpegExperimental, movFastStart, preserveMetadataOnMerge, preserveMovData]);
const openSendReportDialogWithState = useCallback(async (err) => {
const state = {
...commonSettings,
filePath,
fileFormat,
externalFilesMeta,
mainStreams,
copyStreamIdsByFile,
cutSegments: cutSegments.map(s => ({ start: s.start, end: s.end })),
mainFileFormatData,
rotation,
shortestFlag,
effectiveExportMode,
outSegTemplate,
};
openSendReportDialog(err, state);
}, [commonSettings, copyStreamIdsByFile, cutSegments, effectiveExportMode, externalFilesMeta, fileFormat, filePath, mainFileFormatData, mainStreams, outSegTemplate, rotation, shortestFlag]);
const openSendConcatReportDialogWithState = useCallback(async (err, reportState) => {
const state = { ...commonSettings, ...reportState };
openSendReportDialog(err, state);
}, [commonSettings]);
const handleExportFailed = useCallback(async (err) => {
const sendErrorReport = await showExportFailedDialog({ fileFormat, safeOutputFileName });
if (sendErrorReport) openSendReportDialogWithState(err);
}, [fileFormat, safeOutputFileName, openSendReportDialogWithState]);
const handleConcatFailed = useCallback(async (err, reportState) => {
const sendErrorReport = await showConcatFailedDialog({ fileFormat });
if (sendErrorReport) openSendConcatReportDialogWithState(err, reportState);
}, [fileFormat, openSendConcatReportDialogWithState]);
const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: outFormat, fileName: outFileName, clearBatchFilesAfterConcat }) => {
if (workingRef.current) return;
try {
@ -1131,19 +1173,34 @@ const App = memo(() => {
const metadataFromPath = paths[0];
await concatFiles({ paths, outPath, outDir, outFormat, metadataFromPath, includeAllStreams, streams, ffmpegExperimental, onProgress: setCutProgress, preserveMovData, movFastStart, preserveMetadataOnMerge, chapters: chaptersFromSegments, appendFfmpegCommandLog });
if (clearBatchFilesAfterConcat) closeBatch();
if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: i18n.t('Files merged!') });
const notices = [];
if (!includeAllStreams) notices.push(i18n.t('If your source files have more than two tracks, the extra tracks might have been removed. You can change this option before merging.'));
if (!hideAllNotifications) openConcatFinishedToast({ filePath: outPath, notices });
} catch (err) {
if (isOutOfSpaceError(err)) {
showDiskFull();
if (err.killed === true) {
// assume execa killed (aborted by user)
return;
}
errorToast(i18n.t('Failed to merge files. Make sure they are all of the exact same codecs'));
console.error('Failed to merge files', err);
console.error('stdout:', err.stdout);
console.error('stderr:', err.stderr);
if (isExecaFailure(err)) {
if (isOutOfSpaceError(err)) {
showDiskFull();
return;
}
const reportState = { includeAllStreams, streams, outFormat, outFileName, segmentsToChapters };
handleConcatFailed(err, reportState);
return;
}
handleError(err);
} finally {
setWorking();
setCutProgress();
}
}, [setWorking, ensureAccessibleDirectories, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, hideAllNotifications]);
}, [setWorking, ensureAccessibleDirectories, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, hideAllNotifications, handleConcatFailed]);
const cleanupFiles = useCallback(async (cleanupChoices2) => {
// Store paths before we reset state
@ -1260,29 +1317,6 @@ const App = memo(() => {
const getOutSegError = useCallback((fileNames) => getOutSegErrorRaw({ fileNames, filePath, outputDir }), [outputDir, filePath]);
const openSendReportDialogWithState = useCallback(async (err) => {
const state = {
filePath,
fileFormat,
externalFilesMeta,
mainStreams,
copyStreamIdsByFile,
cutSegments: cutSegments.map(s => ({ start: s.start, end: s.end })),
mainFileFormatData,
rotation,
shortestFlag,
effectiveExportMode,
outSegTemplate,
};
openSendReportDialog(err, state);
}, [filePath, fileFormat, externalFilesMeta, mainStreams, copyStreamIdsByFile, cutSegments, mainFileFormatData, rotation, shortestFlag, effectiveExportMode, outSegTemplate]);
const handleExportFailed = useCallback(async (err) => {
const sendErrorReport = await showExportFailedDialog({ detectedFileFormat, safeOutputFileName });
if (sendErrorReport) openSendReportDialogWithState(err);
}, [detectedFileFormat, safeOutputFileName, openSendReportDialogWithState]);
const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []);
const willMerge = segmentsToExport.length > 1 && autoMerge;
@ -1409,7 +1443,7 @@ const App = memo(() => {
console.error('stdout:', err.stdout);
console.error('stderr:', err.stderr);
if (err.exitCode === 1 || err.code === 'ENOENT') {
if (isExecaFailure(err)) {
if (isOutOfSpaceError(err)) {
showDiskFull();
return;

View File

@ -210,7 +210,7 @@ const ConcatDialog = memo(({
)}
</Dialog>
<Dialog isShown={settingsVisible} onCloseComplete={() => setSettingsVisible(false)} title={t('Options')} hasCancel={false} confirmLabel={t('Close')}>
<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)')} />

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { HelpIcon, TickCircleIcon, UnorderedList, ListItem, WarningSignIcon, InfoSignIcon, Checkbox } from 'evergreen-ui';
import { HelpIcon, TickCircleIcon, WarningSignIcon, InfoSignIcon, Checkbox } from 'evergreen-ui';
import Swal from 'sweetalert2';
import i18n from 'i18next';
import { Trans } from 'react-i18next';
@ -369,20 +369,27 @@ export async function createRandomSegments(fileDuration) {
return edl;
}
export async function showExportFailedDialog({ detectedFileFormat, safeOutputFileName }) {
const MovSuggestion = ({ fileFormat }) => fileFormat === 'mp4' && <li><Trans>Change output <b>Format</b> from <b>MP4</b> to <b>MOV</b></Trans></li>;
const OutputFormatSuggestion = () => <li><Trans>Select a different output <b>Format</b> (<b>matroska</b> and <b>mp4</b> support most codecs)</Trans></li>;
const WorkingDirectorySuggestion = () => <li><Trans>Set a different <b>Working directory</b></Trans></li>;
const DifferentFileSuggestion = () => <li><Trans>Try with a <b>Different file</b></Trans></li>;
const HelpSuggestion = () => <li><Trans>See <b>Help</b></Trans> menu</li>;
const ErrorReportSuggestion = () => <li><Trans>If nothing helps, you can send an <b>Error report</b></Trans></li>;
export async function showExportFailedDialog({ fileFormat, safeOutputFileName }) {
const html = (
<div style={{ textAlign: 'left' }}>
<Trans>Try one of the following before exporting again:</Trans>
<ol>
{!safeOutputFileName && <li><Trans>Output file names are not sanitized. Try to enable sanitazion or check your segment labels for invalid characters.</Trans></li>}
{detectedFileFormat === 'mp4' && <li><Trans>Change output <b>Format</b> from <b>MP4</b> to <b>MOV</b></Trans></li>}
<li><Trans>Select a different output <b>Format</b> (<b>matroska</b> and <b>mp4</b> support most codecs)</Trans></li>
<MovSuggestion fileFormat={fileFormat} />
<OutputFormatSuggestion />
<li><Trans>Disable unnecessary <b>Tracks</b></Trans></li>
<li><Trans>Try both <b>Normal cut</b> and <b>Keyframe cut</b></Trans></li>
<li><Trans>Set a different <b>Working directory</b></Trans></li>
<li><Trans>Try with a <b>Different file</b></Trans></li>
<li><Trans>See <b>Help</b></Trans> menu</li>
<li><Trans>If nothing helps, you can send an <b>Error report</b></Trans></li>
<WorkingDirectorySuggestion />
<DifferentFileSuggestion />
<HelpSuggestion />
<ErrorReportSuggestion />
</ol>
</div>
);
@ -391,6 +398,26 @@ export async function showExportFailedDialog({ detectedFileFormat, safeOutputFil
return value;
}
export async function showConcatFailedDialog({ fileFormat }) {
const html = (
<div style={{ textAlign: 'left' }}>
<Trans>Try each of the following before merging again:</Trans>
<ol>
<MovSuggestion fileFormat={fileFormat} />
<OutputFormatSuggestion />
<li><Trans>Disable <b>merge options</b></Trans></li>
<WorkingDirectorySuggestion />
<DifferentFileSuggestion />
<HelpSuggestion />
<ErrorReportSuggestion />
</ol>
</div>
);
const { value } = await ReactSwal.fire({ title: i18n.t('Unable to merge files'), html, timer: null, showConfirmButton: true, showCancelButton: true, cancelButtonText: i18n.t('OK'), confirmButtonText: i18n.t('Report'), reverseButtons: true, focusCancel: true });
return value;
}
export function openYouTubeChaptersDialog(text) {
ReactSwal.fire({
showCloseButton: true,
@ -481,16 +508,36 @@ export async function openDirToast({ filePath, text, html, ...props }) {
if (value) shell.showItemInFolder(filePath);
}
const UnorderedList = ({ children }) => <ul style={{ paddingLeft: '1em' }}>{children}</ul>;
const ListItem = ({ icon: Icon, iconColor, children }) => <li style={{ listStyle: 'none' }}>{Icon && <Icon color={iconColor} size={14} marginRight=".3em" />} {children}</li>;
const Notices = ({ notices }) => notices.map((msg) => <ListItem key={msg} icon={InfoSignIcon} iconColor="info">{msg}</ListItem>);
const Warnings = ({ warnings }) => warnings.map((msg) => <ListItem key={msg} icon={WarningSignIcon} iconColor="warning">{msg}</ListItem>);
const OutputIncorrectSeeHelpMenu = () => <ListItem icon={HelpIcon}>{i18n.t('If output does not look right, see the Help menu.')}</ListItem>;
export async function openCutFinishedToast({ filePath, warnings, notices }) {
const html = (
<UnorderedList>
<ListItem icon={TickCircleIcon} iconColor="success" fontWeight="bold">{i18n.t('Export is done!')}</ListItem>
<ListItem icon={InfoSignIcon}>{i18n.t('Note: cutpoints may be inaccurate. Please test the output files in your desired player/editor before you delete the source file.')}</ListItem>
<ListItem icon={HelpIcon}>{i18n.t('If output does not look right, see the Help menu.')}</ListItem>
{notices.map((msg) => <ListItem key={msg} icon={InfoSignIcon} iconColor="info">{msg}</ListItem>)}
{warnings.map((msg) => <ListItem key={msg} icon={WarningSignIcon} iconColor="warning">{msg}</ListItem>)}
<ListItem icon={InfoSignIcon} iconColor="info">{i18n.t('Note: cutpoints may be inaccurate. Please test the output files in your desired player/editor before you delete the source file.')}</ListItem>
<OutputIncorrectSeeHelpMenu />
<Notices notices={notices} />
<Warnings warnings={warnings} />
</UnorderedList>
);
await openDirToast({ filePath, html, width: 800, position: 'center', timer: 15000 });
await openDirToast({ filePath, html, width: 800, position: 'center', timer: 30000 });
}
export async function openConcatFinishedToast({ filePath, notices }) {
const html = (
<UnorderedList>
<ListItem icon={TickCircleIcon} iconColor="success" fontWeight="bold">{i18n.t('Files merged!')}</ListItem>
<ListItem icon={InfoSignIcon} color="info">{i18n.t('Please test the output files in your desired player/editor before you delete the source files.')}</ListItem>
<OutputIncorrectSeeHelpMenu />
<Notices notices={notices} />
</UnorderedList>
);
await openDirToast({ filePath, html, width: 800, position: 'center', timer: 30000 });
}

View File

@ -5,7 +5,7 @@ import i18n from 'i18next';
import Timecode from 'smpte-timecode';
import { pcmAudioCodecs, getMapStreamsArgs, isMov } from './util/streams';
import { getSuffixedOutPath, getExtensionForFormat, isWindows, isMac, platform, arch } from './util';
import { getSuffixedOutPath, getExtensionForFormat, isWindows, isMac, platform, arch, isExecaFailure } from './util';
import { isDurationValid } from './segments';
import isDev from './isDev';
@ -364,7 +364,7 @@ export async function readFileMeta(filePath) {
return { format, streams, chapters };
} catch (err) {
// Windows will throw error with code ENOENT if format detection fails.
if (err.exitCode === 1 || (isWindows && err.code === 'ENOENT')) {
if (isExecaFailure(err)) {
const err2 = new Error(`Unsupported file: ${err.message}`);
err2.code = 'LLC_FFPROBE_UNSUPPORTED_FILE';
throw err2;

View File

@ -53,6 +53,8 @@ export function openSendReportDialog(err, state) {
<p><Trans>Include the following text:</Trans> <CopyClipboardButton text={text} /></p>
{!isStoreBuild && <p style={{ fontSize: '.8em', color: 'rgba(0,0,0,0.5)' }}><Trans>You might want to redact any sensitive information like paths.</Trans></p>}
<div style={{ fontWeight: 600, fontSize: 12, whiteSpace: 'pre-wrap', color: '#900' }} contentEditable suppressContentEditableWarning>
{text}
</div>

View File

@ -301,9 +301,13 @@ export const deleteDispositionValue = 'llc_disposition_remove';
export const mirrorTransform = 'matrix(-1, 0, 0, 1, 0, 0)';
// I *think* Windows will throw error with code ENOENT if ffprobe/ffmpeg fails (execa), but other OS'es will return this error code if a file is not found, so it would be wrong to attribute it to exec failure.
// see https://github.com/mifi/lossless-cut/issues/451
export const isExecaFailure = (err) => err.exitCode === 1 || (isWindows && err.code === 'ENOENT');
// A bit hacky but it works, unless someone has a file called "No space left on device" ( ͡° ͜ʖ ͡°)
export const isOutOfSpaceError = (err) => (
err && (err.exitCode === 1 || err.code === 'ENOENT')
err && isExecaFailure(err)
&& typeof err.stderr === 'string' && err.stderr.includes('No space left on device')
);