From 1f0a1a4e4dc3560d91c5815ed758d854b3bcc0fb Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sun, 27 Aug 2023 22:07:30 +0200 Subject: [PATCH] allow setting min padding closes #1690 --- public/configStore.js | 1 + src/App.jsx | 6 +- src/components/OutSegTemplateEditor.jsx | 89 ++++++++++++++++--------- src/components/Switch.jsx | 4 +- src/hooks/useUserSettingsRoot.js | 5 +- src/segments.js | 4 +- src/segments.test.js | 3 + src/util/outputNameTemplate.js | 5 +- 8 files changed, 75 insertions(+), 42 deletions(-) diff --git a/public/configStore.js b/public/configStore.js index 40384185..06424a2f 100644 --- a/public/configStore.js +++ b/public/configStore.js @@ -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 diff --git a/src/App.jsx b/src/App.jsx index a6b2a2dd..bc74c106 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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]); diff --git a/src/components/OutSegTemplateEditor.jsx b/src/components/OutSegTemplateEditor.jsx index 02dc8cee..0554d655 100644 --- a/src/components/OutSegTemplateEditor.jsx +++ b/src/components/OutSegTemplateEditor.jsx @@ -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 ( - <> -
-
{outSegFileNames != null && t('Output name(s):', { count: outSegFileNames.length })}
+ +
{outSegFileNames != null && t('Output name(s):', { count: outSegFileNames.length })}
+ + {outSegFileNames != null && {outSegFileNames[currentSegIndexSafe] || outSegFileNames[0] || '-'}} + + + {needToShow && ( + +
+ + + {outSegFileNames != null && } + + + +
- {outSegFileNames != null && {outSegFileNames[currentSegIndexSafe] || outSegFileNames[0] || '-'}} -
+
+ {`${i18n.t('Variables')}:`} + + 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) => ( + onVariableClick(variable)}>{variable} + ))} +
- {needToShow && ( - <> -
- - - {outSegFileNames != null && } - - - -
-
{error != null &&
{i18n.t('There is an error in the file name template:')} {error}
} {isMissingExtension &&
{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 })}
} -
- {`${i18n.t('Variables')}:`} - 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) => ( - onVariableClick(variable)}>{variable} - ))} +
+ + {t('Sanitize file names')}
-
- - )} - + + {hasTextNumericPaddedValue && ( +
+ + Minimum numeric padded length +
+ )} + + )} + + ); }); diff --git a/src/components/Switch.jsx b/src/components/Switch.jsx index 9c787d72..94a0d362 100644 --- a/src/components/Switch.jsx +++ b/src/components/Switch.jsx @@ -3,8 +3,8 @@ import * as RadixSwitch from '@radix-ui/react-switch'; import classes from './Switch.module.css'; -const Switch = ({ checked, disabled, onCheckedChange }) => ( - +const Switch = ({ checked, disabled, onCheckedChange, title, style }) => ( + ); diff --git a/src/hooks/useUserSettingsRoot.js b/src/hooks/useUserSettingsRoot.js index f2561ea4..c4e916ac 100644 --- a/src/hooks/useUserSettingsRoot.js +++ b/src/hooks/useUserSettingsRoot.js @@ -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, }; }; diff --git a/src/segments.js b/src/segments.js index b9d04a81..13986556 100644 --- a/src/segments.js +++ b/src/segments.js @@ -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'); } diff --git a/src/segments.test.js b/src/segments.test.js index a108c71b..b4fd2c1d 100644 --- a/src/segments.test.js +++ b/src/segments.test.js @@ -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'); }); diff --git a/src/util/outputNameTemplate.js b/src/util/outputNameTemplate.js index 694235f3..516c4147 100644 --- a/src/util/outputNameTemplate.js +++ b/src/util/outputNameTemplate.js @@ -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)