improvements

- warn if export options is disabled
- warn if trying to cut with too few keyframes around cutpoint

closes #516

#2780 #2756
pull/2788/head
Mikael Finstad 4 months ago
parent 2602283328
commit 260228d72b
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -19,6 +19,7 @@
"⌘ Cmd": "⌘ Cmd",
"1st, 2nd and 3rd track": "1st, 2nd and 3rd track",
"A mismatch was detected in at least one file. You may proceed, but the resulting file might not be playable.": "A mismatch was detected in at least one file. You may proceed, but the resulting file might not be playable.",
"A segment may result in an unexpectedly long output file length after exporting, because your video file doesn't have any keyframes near the start time of the segment you're trying to cut.": "A segment may result in an unexpectedly long output file length after exporting, because your video file doesn't have any keyframes near the start time of the segment you're trying to cut.",
"Abort": "Abort",
"About LosslessCut": "About LosslessCut",
"Accurate time but could leave an empty portion at the beginning of the video. Equiv to": "Accurate time but could leave an empty portion at the beginning of the video. Equiv to",
@ -260,6 +261,7 @@
"Export mode for {{segments}} segments": "Export mode for {{segments}} segments",
"Export options": "Export options",
"Export options are not shown. You can enable export options by clicking the icon right next to the export button.": "Export options are not shown. You can enable export options by clicking the icon right next to the export button.",
"Export options screen is disabled, and you will not see any important notices or warnings.": "Export options screen is disabled, and you will not see any important notices or warnings.",
"Export options will be shown before exporting.": "Export options will be shown before exporting.",
"Export options will not be shown before exporting.": "Export options will not be shown before exporting.",
"Export project": "Export project",
@ -425,6 +427,7 @@
"Label selected segments": "Label selected segments",
"Language": "Language",
"Last ffmpeg commands": "Last ffmpeg commands",
"Learn more": "Learn more",
"Learn More": "Learn More",
"Less than 2 frames found": "Less than 2 frames found",
"Licenses": "Licenses",

@ -2683,7 +2683,7 @@ function App() {
{/* Dialogs */}
<ExportConfirm areWeCutting={areWeCutting} segmentsOrInverse={segmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmOpen} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} cutFileTemplate={cutFileTemplateOrDefault} cutMergedFileTemplate={cutMergedFileTemplateOrDefault} generateCutFileNames={generateCutFileNames} generateCutMergedFileNames={generateCutMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} isEncoding={isEncoding} encBitrate={encBitrate} setEncBitrate={setEncBitrate} toggleSettings={toggleSettings} outputPlaybackRate={outputPlaybackRate} lossyMode={lossyMode} />
<ExportConfirm areWeCutting={areWeCutting} segmentsOrInverse={segmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmOpen} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} cutFileTemplate={cutFileTemplateOrDefault} cutMergedFileTemplate={cutMergedFileTemplateOrDefault} generateCutFileNames={generateCutFileNames} generateCutMergedFileNames={generateCutMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} isEncoding={isEncoding} encBitrate={encBitrate} setEncBitrate={setEncBitrate} toggleSettings={toggleSettings} outputPlaybackRate={outputPlaybackRate} lossyMode={lossyMode} neighbouringKeyFrames={neighbouringKeyFrames} findNearestKeyFrameTime={findNearestKeyFrameTime} />
<Dialog.Root open={streamsSelectorShown} onOpenChange={setStreamsSelectorShown}>
<Dialog.Portal>

@ -4,7 +4,7 @@ import { motion } from 'motion/react';
import { MdRotate90DegreesCcw } from 'react-icons/md';
import { useTranslation } from 'react-i18next';
import { IoIosCamera, IoMdKey, IoMdSpeedometer } from 'react-icons/io';
import { FaYinYang, FaTrashAlt, FaStepBackward, FaStepForward, FaCaretLeft, FaCaretRight, FaPause, FaPlay, FaImages, FaKey } from 'react-icons/fa';
import { FaYinYang, FaTrashAlt, FaStepBackward, FaStepForward, FaCaretLeft, FaCaretRight, FaPause, FaPlay, FaImages, FaKey, FaExclamationTriangle } from 'react-icons/fa';
import { GiSoundWaves } from 'react-icons/gi';
// import useTraceUpdate from 'use-trace-update';
import invariant from 'tiny-invariant';
@ -607,7 +607,8 @@ function BottomBar({
</div>
)}
{(!simpleMode || !exportConfirmEnabled) && <ToggleExportConfirm style={{ marginLeft: '.4em' }} />}
{!exportConfirmEnabled && (<FaExclamationTriangle style={{ color: dangerColor, marginLeft: '.4em' }} title={t('Export options screen is disabled, and you will not see any important notices or warnings.')} />)}
{(!simpleMode || !exportConfirmEnabled) && <ToggleExportConfirm style={{ marginLeft: exportConfirmEnabled ? '.4em' : undefined }} />}
{isFileOpened && (
<ExportButton segmentsToExport={segmentsToExport} areWeCutting={areWeCutting} onClick={withBlur(onExportPress)} />

@ -30,6 +30,12 @@ import ExportSheet from './ExportSheet';
import ToggleExportConfirm from './ToggleExportConfirm';
import type { LossyMode } from '../../../main';
import AnimatedTr from './AnimatedTr';
import type { Frame } from '../ffmpeg';
import type { FindNearestKeyframeTime } from '../hooks/useKeyframes';
import { troubleshootingUrl } from '../../../common/constants';
const remote = window.require('@electron/remote');
const { shell } = remote;
const noticeStyle: CSSProperties = { marginBottom: '.5em' };
@ -63,12 +69,24 @@ function renderNoticeIcon(notice: { warning?: boolean | undefined } | undefined,
);
}
function renderNotice(notice: { warning?: boolean | undefined, text: ReactNode } | undefined, { key, style }: { key?: string, style?: CSSProperties }) {
interface Notice {
warning?: true,
text: ReactNode,
}
interface GenericNotice {
warning?: true,
text: string,
url?: string,
}
function renderNotice(notice: Notice | GenericNotice | undefined, { style }: { style?: CSSProperties }) {
if (notice == null) return null;
const { warning, text } = notice;
const url = 'url' in notice ? notice.url : undefined;
return (
<div key={key} style={{ ...(warning ? warningStyle : infoStyle), gap: '0 .5em', ...style }}>
{renderNoticeIcon({ warning })} {text}
<div key={typeof notice.text === 'string' ? notice.text : undefined} style={{ ...(warning ? warningStyle : infoStyle), display: 'flex', alignItems: 'center', gap: '0 .5em', ...style }}>
{renderNoticeIcon({ warning }, { fontSize: '1em', flexShrink: 0 })} <span style={{ fontSize: '.9em' }}>{text}</span>{url != null && <IoIosHelpCircle style={{ cursor: 'pointer', fontSize: '1.5em', flexShrink: 0, color: primaryTextColor }} title={i18n.t('Learn more')} role="button" tabIndex={0} onClick={() => shell.openExternal(url)} />}
</div>
);
}
@ -100,6 +118,8 @@ function ExportConfirm({
toggleSettings,
outputPlaybackRate,
lossyMode,
neighbouringKeyFrames,
findNearestKeyFrameTime,
} : {
areWeCutting: boolean,
segmentsToExport: SegmentToExport[],
@ -127,10 +147,12 @@ function ExportConfirm({
toggleSettings: () => void,
outputPlaybackRate: number,
lossyMode: LossyMode | undefined,
neighbouringKeyFrames: Frame[],
findNearestKeyFrameTime: FindNearestKeyframeTime,
}) {
const { t } = useTranslation();
const { changeOutDir, keyframeCut, toggleKeyframeCut, preserveMovData, setPreserveMovData, preserveMetadata, setPreserveMetadata, preserveChapters, setPreserveChapters, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoDeleteMergedSegments, exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, enableSmartCut, setEnableSmartCut, effectiveExportMode, enableOverwriteOutput, setEnableOverwriteOutput, ffmpegExperimental, setFfmpegExperimental, cutFromAdjustmentFrames, setCutFromAdjustmentFrames, cutToAdjustmentFrames, setCutToAdjustmentFrames, setCutFileTemplate, setCutMergedFileTemplate, simpleMode } = useUserSettings();
const { changeOutDir, keyframeCut, toggleKeyframeCut, preserveMovData, setPreserveMovData, preserveMetadata, setPreserveMetadata, preserveChapters, setPreserveChapters, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoDeleteMergedSegments, exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, enableSmartCut, setEnableSmartCut, effectiveExportMode, enableOverwriteOutput, setEnableOverwriteOutput, ffmpegExperimental, setFfmpegExperimental, cutFromAdjustmentFrames, setCutFromAdjustmentFrames, cutToAdjustmentFrames, setCutToAdjustmentFrames, setCutFileTemplate, setCutMergedFileTemplate, simpleMode, keyframesEnabled } = useUserSettings();
const [showAdvanced, setShowAdvanced] = useState(!simpleMode);
@ -146,8 +168,19 @@ function ExportConfirm({
// some thumbnail streams (png,jpg etc) cannot always be cut correctly, so we warn if they try to.
const areWeCuttingProblematicStreams = areWeCutting && mainCopiedThumbnailStreams.length > 0;
const haveSegmentWithProblematicKeyframe = useMemo(() => {
if (neighbouringKeyFrames.length === 0) return false;
return segmentsToExport.some(({ start, end }) => {
const previousKeyframeTime = findNearestKeyFrameTime({ time: start, direction: -1 }) ?? 0;
const segmentDuration = end - start;
const estimatedExportedSegmentDuration = end - previousKeyframeTime;
// if estimated actual output length of segment is more than 1.5 times the intended segment duration, then we consider it problematic and warn the user about it.
return estimatedExportedSegmentDuration > segmentDuration * 1.5;
});
}, [neighbouringKeyFrames.length, segmentsToExport, findNearestKeyFrameTime]);
const notices = useMemo(() => {
const specific: Record<'exportMode' | 'problematicStreams' | 'movFastStart' | 'preserveMovData' | 'smartCut' | 'cutMode' | 'avoidNegativeTs' | 'overwriteOutput', { warning?: true, text: ReactNode } | undefined> = {
const specific: Record<'exportMode' | 'problematicStreams' | 'movFastStart' | 'preserveMovData' | 'smartCut' | 'cutMode' | 'avoidNegativeTs' | 'overwriteOutput', Notice | undefined> = {
exportMode: effectiveExportMode === 'segments_to_chapters' ? { text: i18n.t('Segments to chapters mode is active, this means that the file will not be cut. Instead chapters will be created from the segments.') } : undefined,
problematicStreams: areWeCuttingProblematicStreams ? { warning: true, text: <Trans>Warning: Cutting thumbnail tracks is known to cause problems. Consider disabling track {{ trackNumber: mainCopiedThumbnailStreams[0] ? mainCopiedThumbnailStreams[0].index + 1 : 0 }}.</Trans> } : undefined,
movFastStart: isMov && isIpod && !movFastStart ? { warning: true, text: t('For the ipod format, it is recommended to activate this option') } : undefined,
@ -169,18 +202,23 @@ function ExportConfirm({
overwriteOutput: enableOverwriteOutput ? { text: t('Existing files will be overwritten without warning!') } : undefined,
};
const generic: { warning?: true, text: string }[] = [];
const generic: GenericNotice[] = [];
if ((effectiveExportMode === 'separate' || effectiveExportMode === 'merge' || effectiveExportMode === 'merge+separate') && !areWeCutting) {
generic.push({ text: t('Exporting whole file without cutting, because there are no segments to export.') });
}
// https://github.com/mifi/lossless-cut/issues/1809
if (areWeCutting && outFormat === 'flac') {
generic.push({ warning: true, text: t('There is a known issue in FFmpeg with cutting FLAC files. The file will be re-encoded, which is still lossless, but the export may be slower.') });
}
if (areWeCutting && outputPlaybackRate !== 1) {
generic.push({ warning: true, text: t('Adjusting the output FPS and cutting at the same time will cause incorrect cuts. Consider instead doing it in two separate steps.') });
if (areWeCutting) {
// https://github.com/mifi/lossless-cut/issues/1809
if (outFormat === 'flac') {
generic.push({ text: t('There is a known issue in FFmpeg with cutting FLAC files. The file will be re-encoded, which is still lossless, but the export may be slower.') });
}
if (outputPlaybackRate !== 1) {
generic.push({ warning: true, text: t('Adjusting the output FPS and cutting at the same time will cause incorrect cuts. Consider instead doing it in two separate steps.') });
}
if (keyframesEnabled && haveSegmentWithProblematicKeyframe) {
generic.push({ warning: true, text: t('A segment may result in an unexpectedly long output file length after exporting, because your video file doesn\'t have any keyframes near the start time of the segment you\'re trying to cut.'), url: troubleshootingUrl });
}
}
return {
@ -188,7 +226,7 @@ function ExportConfirm({
specific,
totalNum: generic.filter((n) => n.warning).length + Object.values(specific).filter((n) => n != null && n.warning).length,
};
}, [areWeCutting, areWeCuttingProblematicStreams, avoidNegativeTs, effectiveExportMode, enableOverwriteOutput, isEncoding, isIpod, isMov, keyframeCut, mainCopiedThumbnailStreams, movFastStart, needSmartCut, outFormat, outputPlaybackRate, preserveMovData, t, willMerge]);
}, [areWeCutting, areWeCuttingProblematicStreams, avoidNegativeTs, effectiveExportMode, enableOverwriteOutput, isEncoding, isIpod, isMov, keyframeCut, keyframesEnabled, mainCopiedThumbnailStreams, movFastStart, needSmartCut, outFormat, outputPlaybackRate, preserveMovData, haveSegmentWithProblematicKeyframe, t, willMerge]);
const exportModeDescription = useMemo(() => ({
segments_to_chapters: t('Don\'t cut the file, but instead export an unmodified original which has chapters generated from segments'),
@ -308,9 +346,7 @@ function ExportConfirm({
<tbody>
<tr>
<td colSpan={2}>
{notices.generic.map(({ warning, text }) => (
renderNotice({ warning, text }, { key: text })
))}
{notices.generic.map((notice) => renderNotice(notice, {}))}
</td>
<td />
</tr>
@ -327,7 +363,7 @@ function ExportConfirm({
<tr>
<td>
{segmentsOrInverse.selected.length > 1 ? t('Export mode for {{segments}} segments', { segments: segmentsOrInverse.selected.length }) : t('Export mode')}
{renderNotice(notices.specific['exportMode'], { style: { fontSize: '85%' } })}
{renderNotice(notices.specific['exportMode'], {})}
</td>
<td>
<ExportModeButton selectedSegments={segmentsOrInverse.selected} style={{ height: '1.8em' }} />
@ -352,7 +388,7 @@ function ExportConfirm({
<tr>
<td>
<Trans>Input has {{ numStreamsTotal }} tracks</Trans>
{renderNotice(notices.specific['problematicStreams'], { style: { fontSize: '85%' } })}
{renderNotice(notices.specific['problematicStreams'], {})}
</td>
<td>
<HighlightedText style={{ cursor: 'pointer' }} onClick={onShowStreamsSelectorClick}><Trans>Keeping {{ numStreamsToCopy }} tracks</Trans></HighlightedText>
@ -397,7 +433,7 @@ function ExportConfirm({
<tr>
<td>
{t('Overwrite existing files')}
{renderNotice(notices.specific['overwriteOutput'], { style: { fontSize: '85%' } })}
{renderNotice(notices.specific['overwriteOutput'], {})}
</td>
<td>
<Switch checked={enableOverwriteOutput} onCheckedChange={setEnableOverwriteOutput} />
@ -465,7 +501,7 @@ function ExportConfirm({
</td>
<td>
<Switch checked={movFastStart} onCheckedChange={toggleMovFastStart} />
{renderNotice(notices.specific['movFastStart'], { style: { fontSize: '85%' } })}
{renderNotice(notices.specific['movFastStart'], {})}
</td>
<td>
{renderNoticeIcon(notices.specific['movFastStart'], rightIconStyle) ?? <HelpIcon onClick={onMovFastStartHelpPress} />}
@ -475,7 +511,7 @@ function ExportConfirm({
<AnimatedTr>
<td>
{t('Preserve all MP4/MOV metadata?')}
{renderNotice(notices.specific['preserveMovData'], { style: { fontSize: '85%' } })}
{renderNotice(notices.specific['preserveMovData'], {})}
</td>
<td>
<Switch checked={preserveMovData} onCheckedChange={togglePreserveMovData} />
@ -548,7 +584,7 @@ function ExportConfirm({
<AnimatedTr>
<td>
{t('Smart cut (experimental):')}
{renderNotice(notices.specific['smartCut'], { style: { fontSize: '85%' } })}
{renderNotice(notices.specific['smartCut'], {})}
</td>
<td>
<Switch checked={enableSmartCut} onCheckedChange={() => setEnableSmartCut((v) => !v)} />
@ -562,7 +598,7 @@ function ExportConfirm({
<AnimatedTr>
<td>
{t('Keyframe cut mode')}
{renderNotice(notices.specific['cutMode'], { style: { fontSize: '85%' } })}
{renderNotice(notices.specific['cutMode'], {})}
</td>
<td>
<Switch checked={keyframeCut} onCheckedChange={() => toggleKeyframeCut()} />
@ -613,7 +649,7 @@ function ExportConfirm({
<AnimatedTr>
<td>
&quot;ffmpeg&quot; <code className="highlighted">avoid_negative_ts</code>
{renderNotice(notices.specific['avoidNegativeTs'], { style: { fontSize: '85%' } })}
{renderNotice(notices.specific['avoidNegativeTs'], {})}
</td>
<td>
<Select value={avoidNegativeTs} onChange={(e) => setAvoidNegativeTs(e.target.value as AvoidNegativeTs)} style={{ height: 20, marginLeft: 5 }}>

@ -72,15 +72,19 @@ export interface Frame {
}
export async function readFrames({ filePath, from, to, streamIndex }: {
filePath: string, from?: number | undefined, to?: number | undefined, streamIndex: number,
filePath: string,
from?: number | undefined,
to?: number | undefined,
streamIndex: number,
}) {
const intervalsArgs = from != null && to != null ? ['-read_intervals', `${from}%${to}`] : [];
const { stdout } = await runFfprobe(['-v', 'error', ...intervalsArgs, '-show_packets', '-select_streams', String(streamIndex), '-show_entries', 'packet=pts_time,flags', '-of', 'json', filePath], { logCli: false });
const createdAt = new Date();
const packetsFiltered: Frame[] = (JSON.parse(new TextDecoder().decode(stdout)).packets as { flags: string, pts_time: string }[])
.map((p) => ({
keyframe: p.flags[0] === 'K',
time: parseFloat(p.pts_time),
createdAt: new Date(),
createdAt,
}))
.filter((p) => !Number.isNaN(p.time));

@ -108,4 +108,6 @@ function useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream,
};
}
export type FindNearestKeyframeTime = ReturnType<typeof useKeyframes>['findNearestKeyFrameTime'];
export default useKeyframes;

Loading…
Cancel
Save