mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-25 19:52:44 +01:00
parent
a382d040dd
commit
1f0a1a4e4d
@ -128,6 +128,7 @@ const defaults = {
|
||||
allowMultipleInstances: false,
|
||||
darkMode: true,
|
||||
preferStrongColors: false,
|
||||
outputFileNameMinZeroPadding: 1,
|
||||
};
|
||||
|
||||
// For portable app: https://github.com/mifi/lossless-cut/issues/645
|
||||
|
@ -185,7 +185,7 @@ const App = memo(() => {
|
||||
const allUserSettings = useUserSettingsRoot();
|
||||
|
||||
const {
|
||||
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors,
|
||||
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding,
|
||||
} = allUserSettings;
|
||||
|
||||
useEffect(() => {
|
||||
@ -1100,8 +1100,8 @@ const App = memo(() => {
|
||||
}, [isFileOpened, cleanupChoices, askForCleanupChoices, cleanupFiles, setWorking]);
|
||||
|
||||
const generateOutSegFileNames = useCallback(({ segments = segmentsToExport, template, forceSafeOutputFileName }) => (
|
||||
generateOutSegFileNamesRaw({ segments, template, forceSafeOutputFileName, formatTimecode, isCustomFormatSelected, fileFormat, filePath, safeOutputFileName, maxLabelLength })
|
||||
), [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, safeOutputFileName, segmentsToExport]);
|
||||
generateOutSegFileNamesRaw({ segments, template, forceSafeOutputFileName, formatTimecode, isCustomFormatSelected, fileFormat, filePath, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding })
|
||||
), [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);
|
||||
|
||||
const getOutSegError = useCallback((fileNames) => getOutSegErrorRaw({ fileNames, filePath, outputDir, safeOutputFileName }), [filePath, outputDir, safeOutputFileName]);
|
||||
|
||||
|
@ -1,27 +1,31 @@
|
||||
import React, { memo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import i18n from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WarningSignIcon, ErrorIcon, Button, IconButton, TickIcon, ResetIcon } from 'evergreen-ui';
|
||||
import withReactContent from 'sweetalert2-react-content';
|
||||
import { IoIosHelpCircle } from 'react-icons/io';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
import Swal from '../swal';
|
||||
import HighlightedText from './HighlightedText';
|
||||
import { defaultOutSegTemplate, segNumVariable } from '../util/outputNameTemplate';
|
||||
import { defaultOutSegTemplate, segNumVariable, segSuffixVariable } from '../util/outputNameTemplate';
|
||||
import useUserSettings from '../hooks/useUserSettings';
|
||||
import Switch from './Switch';
|
||||
import Select from './Select';
|
||||
|
||||
const ReactSwal = withReactContent(Swal);
|
||||
|
||||
const electron = window.require('electron');
|
||||
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
const extVar = '${EXT}';
|
||||
const formatVariable = (variable) => `\${${variable}}`;
|
||||
|
||||
const extVar = formatVariable('EXT');
|
||||
|
||||
const inputStyle = { flexGrow: 1, fontFamily: 'inherit', fontSize: '.8em', backgroundColor: 'var(--gray3)', color: 'var(--gray12)', border: '1px solid var(--gray7)', appearance: 'none' };
|
||||
|
||||
const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe, getOutSegError }) => {
|
||||
const { safeOutputFileName, toggleSafeOutputFileName } = useUserSettings();
|
||||
const { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding } = useUserSettings();
|
||||
|
||||
const [text, setText] = useState(outSegTemplate);
|
||||
const [debouncedText] = useDebounce(text, 500);
|
||||
@ -33,6 +37,8 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasTextNumericPaddedValue = useMemo(() => [segNumVariable, segSuffixVariable].some((v) => debouncedText.includes(formatVariable(v))), [debouncedText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedText == null) return;
|
||||
|
||||
@ -92,44 +98,63 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
|
||||
const startPos = input.selectionStart;
|
||||
const endPos = input.selectionEnd;
|
||||
|
||||
const newValue = `${text.slice(0, startPos)}${`\${${variable}}${text.slice(endPos)}`}`;
|
||||
const newValue = `${text.slice(0, startPos)}${`${formatVariable(variable)}${text.slice(endPos)}`}`;
|
||||
setText(newValue);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div>{outSegFileNames != null && t('Output name(s):', { count: outSegFileNames.length })}</div>
|
||||
<motion.div animate={{ margin: needToShow ? '1.5em 0' : 0, maxWidth: 600 }}>
|
||||
<div>{outSegFileNames != null && t('Output name(s):', { count: outSegFileNames.length })}</div>
|
||||
|
||||
{outSegFileNames != null && <HighlightedText role="button" onClick={onShowClick} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', cursor: needToShow ? undefined : 'pointer' }}>{outSegFileNames[currentSegIndexSafe] || outSegFileNames[0] || '-'}</HighlightedText>}
|
||||
</div>
|
||||
{outSegFileNames != null && <HighlightedText role="button" onClick={onShowClick} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', cursor: needToShow ? undefined : 'pointer' }}>{outSegFileNames[currentSegIndexSafe] || outSegFileNames[0] || '-'}</HighlightedText>}
|
||||
|
||||
{needToShow && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 5, marginTop: 10 }}>
|
||||
<input type="text" ref={inputRef} style={inputStyle} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" />
|
||||
<AnimatePresence>
|
||||
{needToShow && (
|
||||
<motion.div
|
||||
key="1"
|
||||
initial={{ opacity: 0, height: 0, marginTop: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto', marginTop: '1em' }}
|
||||
exit={{ opacity: 0, height: 0, marginTop: 0 }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '.2em' }}>
|
||||
<input type="text" ref={inputRef} style={inputStyle} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" />
|
||||
|
||||
{outSegFileNames != null && <Button height={20} onClick={onAllSegmentsPreviewPress} marginLeft={5}>{t('Preview')}</Button>}
|
||||
|
||||
<IconButton title={t('Reset')} icon={ResetIcon} height={20} onClick={reset} marginLeft={5} intent="danger" />
|
||||
<IconButton title={t('Close')} icon={TickIcon} height={20} onClick={onHideClick} marginLeft={5} intent="success" />
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center', marginBottom: '.7em' }}>
|
||||
{`${i18n.t('Variables')}:`}
|
||||
|
||||
<IoIosHelpCircle fontSize="1.3em" color="var(--gray12)" role="button" cursor="pointer" onClick={() => electron.shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/import-export.md#customising-exported-file-names')} />
|
||||
{['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, 'SEG_LABEL', segSuffixVariable, 'EXT', 'SEG_TAGS.XX', 'EPOCH_MS'].map((variable) => (
|
||||
<span key={variable} role="button" style={{ cursor: 'pointer', marginRight: '.2em', textDecoration: 'underline', textDecorationStyle: 'dashed', fontSize: '.9em' }} onClick={() => onVariableClick(variable)}>{variable}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{outSegFileNames != null && <Button height={20} onClick={onAllSegmentsPreviewPress} marginLeft={5}>{t('Preview')}</Button>}
|
||||
<Button title={t('Whether or not to sanitize output file names (sanitizing removes special characters)')} marginLeft={5} height={20} onClick={toggleSafeOutputFileName} intent={safeOutputFileName ? 'success' : 'danger'}>{safeOutputFileName ? t('Sanitize') : t('No sanitize')}</Button>
|
||||
<IconButton title={t('Reset')} icon={ResetIcon} height={20} onClick={reset} marginLeft={5} intent="danger" />
|
||||
<IconButton title={t('Close')} icon={TickIcon} height={20} onClick={onHideClick} marginLeft={5} intent="success" />
|
||||
</div>
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
{error != null && <div style={{ marginBottom: '1em' }}><ErrorIcon color="var(--red9)" size={14} verticalAlign="baseline" /> {i18n.t('There is an error in the file name template:')} {error}</div>}
|
||||
|
||||
{isMissingExtension && <div style={{ marginBottom: '1em' }}><WarningSignIcon color="var(--amber9)" /> {i18n.t('The file name template is missing {{ext}} and will result in a file without the suggested extension. This may result in an unplayable output file.', { ext: extVar })}</div>}
|
||||
|
||||
<div style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{`${i18n.t('Variables')}:`}
|
||||
<IoIosHelpCircle fontSize="1.3em" color="var(--gray12)" role="button" cursor="pointer" onClick={() => electron.shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/import-export.md#customising-exported-file-names')} />
|
||||
{['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, 'SEG_LABEL', 'SEG_SUFFIX', 'EXT', 'SEG_TAGS.XX', 'EPOCH_MS'].map((variable) => (
|
||||
<span key={variable} role="button" style={{ cursor: 'pointer', marginRight: '.2em' }} onClick={() => onVariableClick(variable)}>{variable}</span>
|
||||
))}
|
||||
<div title={t('Whether or not to sanitize output file names (sanitizing removes special characters)')} style={{ marginBottom: '.3em' }}>
|
||||
<Switch checked={safeOutputFileName} onCheckedChange={toggleSafeOutputFileName} style={{ verticalAlign: 'middle', marginRight: '.5em' }} />
|
||||
<span>{t('Sanitize file names')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
{hasTextNumericPaddedValue && (
|
||||
<div style={{ marginBottom: '.3em' }}>
|
||||
<Select value={outputFileNameMinZeroPadding} onChange={(e) => setOutputFileNameMinZeroPadding(parseInt(e.target.value, 10))} style={{ marginRight: '1em' }}>
|
||||
{Array.from({ length: 10 }).map((v, i) => i + 1).map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</Select>
|
||||
Minimum numeric padded length
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -3,8 +3,8 @@ import * as RadixSwitch from '@radix-ui/react-switch';
|
||||
|
||||
import classes from './Switch.module.css';
|
||||
|
||||
const Switch = ({ checked, disabled, onCheckedChange }) => (
|
||||
<RadixSwitch.Root disabled={disabled} className={classes.SwitchRoot} checked={checked} onCheckedChange={onCheckedChange}>
|
||||
const Switch = ({ checked, disabled, onCheckedChange, title, style }) => (
|
||||
<RadixSwitch.Root disabled={disabled} className={classes.SwitchRoot} checked={checked} onCheckedChange={onCheckedChange} style={style} title={title}>
|
||||
<RadixSwitch.Thumb className={classes.SwitchThumb} />
|
||||
</RadixSwitch.Root>
|
||||
);
|
||||
|
@ -141,7 +141,8 @@ export default () => {
|
||||
useEffect(() => safeSetConfig({ darkMode }), [darkMode]);
|
||||
const [preferStrongColors, setPreferStrongColors] = useState(safeGetConfigInitial('preferStrongColors'));
|
||||
useEffect(() => safeSetConfig({ preferStrongColors }), [preferStrongColors]);
|
||||
|
||||
const [outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding] = useState(safeGetConfigInitial('outputFileNameMinZeroPadding'));
|
||||
useEffect(() => safeSetConfig({ outputFileNameMinZeroPadding }), [outputFileNameMinZeroPadding]);
|
||||
|
||||
const resetKeyBindings = useCallback(() => {
|
||||
configStore.reset('keyBindings');
|
||||
@ -258,5 +259,7 @@ export default () => {
|
||||
setDarkMode,
|
||||
preferStrongColors,
|
||||
setPreferStrongColors,
|
||||
outputFileNameMinZeroPadding,
|
||||
setOutputFileNameMinZeroPadding,
|
||||
};
|
||||
};
|
||||
|
@ -235,7 +235,7 @@ export function playOnlyCurrentSegment({ playbackMode, currentTime, playingSegme
|
||||
|
||||
export const getNumDigits = (value) => Math.floor(value > 0 ? Math.log10(value) : 0) + 1;
|
||||
|
||||
export function formatSegNum(segIndex, numSegments) {
|
||||
export function formatSegNum(segIndex, numSegments, minLength = 0) {
|
||||
const numDigits = getNumDigits(numSegments);
|
||||
return `${segIndex + 1}`.padStart(numDigits, '0');
|
||||
return `${segIndex + 1}`.padStart(Math.max(numDigits, minLength), '0');
|
||||
}
|
||||
|
@ -113,4 +113,7 @@ it('detects overlapping segments, undefined end', () => {
|
||||
test('formatSegNum', () => {
|
||||
expect(formatSegNum(0, 9)).toBe('1');
|
||||
expect(formatSegNum(0, 10)).toBe('01');
|
||||
|
||||
expect(formatSegNum(0, 10, 2)).toBe('01');
|
||||
expect(formatSegNum(0, 10, 3)).toBe('001');
|
||||
});
|
||||
|
@ -7,6 +7,7 @@ import { getSegmentTags, formatSegNum } from '../segments';
|
||||
|
||||
|
||||
export const segNumVariable = 'SEG_NUM';
|
||||
export const segSuffixVariable = 'SEG_SUFFIX';
|
||||
|
||||
const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize } = window.require('path');
|
||||
|
||||
@ -89,12 +90,12 @@ function interpolateSegmentFileName({ template, epochMs, inputFileNameWithoutExt
|
||||
return compiled(data);
|
||||
}
|
||||
|
||||
export function generateOutSegFileNames({ segments, template, forceSafeOutputFileName, formatTimecode, isCustomFormatSelected, fileFormat, filePath, safeOutputFileName, maxLabelLength }) {
|
||||
export function generateOutSegFileNames({ segments, template, forceSafeOutputFileName, formatTimecode, isCustomFormatSelected, fileFormat, filePath, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }) {
|
||||
const epochMs = Date.now();
|
||||
|
||||
return segments.map((segment, i) => {
|
||||
const { start, end, name = '' } = segment;
|
||||
const segNum = formatSegNum(i, segments.length);
|
||||
const segNum = formatSegNum(i, segments.length, outputFileNameMinZeroPadding);
|
||||
|
||||
// Fields that did not come from the source file's name must be sanitized, because they may contain characters that are not supported by the target operating/file system
|
||||
// however we disable this when the user has chosen to (safeOutputFileName === false)
|
||||
|
Loading…
Reference in New Issue
Block a user