add template variables

FILE_EXPORT_COUNT
EXPORT_COUNT

closes #2296
pull/2320/head
Mikael Finstad 1 year ago
parent df1f6acd8b
commit 572f20981c
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -2,28 +2,38 @@
## Customising exported file names
When exporting multiple segments as separate files, LosslessCut offers you the ability to specify how the output files will be named in sequence using a *template string*. The template string is evaluated as a [JavaScript template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals), so you can use JavaScript syntax inside of it. The following variables are available in the template to customize the filenames:
| Avail when merging? | Variable | Output |
| - | - | - |
| ✅ | `${FILENAME}` | The original filename *without the extension* (e.g. `Beach Trip` for a file named `Beach Trip.mp4`).
| ✅ | `${EXT}` | The extension of the file (e.g.: `.mp4`, `.mkv`).
| ✅ | `${EPOCH_MS}` | Number of milliseconds since epoch (e.g. `1680852771465`). Useful to generate a unique file name on every export to prevent accidental overwrite.
| | `${SEG_NUM}` | Number of the segment, padded string (e.g. `01`, `02` or `42`).
| | `${SEG_NUM_INT}` | Number of the segment, as a raw integer (e.g. `1`, `2` or `42`). Can be used with numeric arithmetics, e.g. `${SEG_NUM_INT+100}`.
| | `${SEG_LABEL}` | The label of the segment (e.g. `Getting_Lunch`).
| | `${SEG_SUFFIX}` | If a label exists for this segment, the label will be used, prepended by `-`. Otherwise, the segment number prepended by `-seg` will be used (e.g. `-Getting_Lunch`, `-seg1`).
| | `${CUT_FROM}` | The timestamp for the beginning of the segment in `hh.mm.ss.sss` format (e.g. `00.00.27.184`).
| | `${CUT_TO}` | The timestamp for the ending of the segment in `hh.mm.ss.sss` format (e.g. `00.00.28.000`).
| | `${SEG_TAGS.XX}` | Allows you to retrieve the tags for a given segment by name. If a tag is called foo, it can be accessed with `${SEG_TAGS.foo}`. Note that if the tag does not exist, it will return the text `undefined`. You can work around this as follows: `${SEG_TAGS.foo ?? ''}`
Your files must always include at least one unique identifer (such as `${SEG_NUM}` or `${CUT_FROM}`), and it should end in `${EXT}` (or else players might not recognise the files). For instance, to achieve a filename sequence of `Beach Trip - 1.mp4`, `Beach Trip - 2.mp4`, `Beach Trip - 3.mp4`, your format should read `${FILENAME} - ${SEG_NUM}${EXT}`
When exporting multiple segments as separate files, LosslessCut offers you the ability to specify how the output files will be named in sequence using a *template string*. The template string is evaluated as a [JavaScript template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals), so you can use JavaScript syntax inside of it.
## Import/export projects
The following variables are available in the template to customize the filenames:
| Avail when merging? | Variable | Type | Output |
| - | - | - | - |
| ✅ | `${FILENAME}` | `string` | The original filename *without the extension* (e.g. `Beach Trip` for a file named `Beach Trip.mp4`).
| ✅ | `${EXT}` | `string` | The extension of the file (e.g.: `.mp4`, `.mkv`).
| ✅ | `${EPOCH_MS}` | `number` | Number of milliseconds since epoch (e.g. `1680852771465`). Useful to generate a unique file name on every export to prevent accidental overwrite.
| ✅ | `${EXPORT_COUNT}` | `number` | Number of exports done since last LosslessCut launch (starts at 0).
| | `${FILE_EXPORT_COUNT}` | `number` | Number of exports done since last file was opened (starts at 0).
| | `${SEG_NUM}` | `string` | Segment index, padded string (e.g. `01`, `02` or `42`).
| | `${SEG_NUM_INT}` | `number` | Segment index, as an integer (e.g. `1`, `2` or `42`). Can be used with numeric arithmetics, e.g. `${SEG_NUM_INT+100}`.
| | `${SEG_LABEL}` | `string` | The label of the segment (e.g. `Getting Lunch`).
| | `${SEG_SUFFIX}` | `string` | If a label exists for this segment, the label will be used, prepended by `-`. Otherwise, the segment index prepended by `-seg` will be used (e.g. `-Getting_Lunch`, `-seg1`).
| | `${CUT_FROM}` | `string` | The timestamp for the beginning of the segment in `hh.mm.ss.sss` format (e.g. `00.00.27.184`).
| | `${CUT_TO}` | `string` | The timestamp for the ending of the segment in `hh.mm.ss.sss` format (e.g. `00.00.28.000`).
| | `${SEG_TAGS.XX}` | `object` | Allows you to retrieve the tags for a given segment by name. If a tag is called foo, it can be accessed with `${SEG_TAGS.foo}`. Note that if the tag does not exist, it will yield the text `undefined`. You can work around this as follows: `${SEG_TAGS.foo ?? ''}`
Your files must always include at least one unique identifer (such as `${SEG_NUM}` or `${CUT_FROM}`), and it should end in `${EXT}` (or else players might not recognise the files). For instance, to achieve a filename sequence of `Beach Trip - 1.mp4`, `Beach Trip - 2.mp4`, `Beach Trip - 3.mp4`, your format should read `${FILENAME} - ${SEG_NUM}${EXT}`. If your template gives at least two duplicate output file names, LosslessCut will revert to using the default template instead.
### JavaScript tips
LosslessCut also allows importing/exporting your project (segments) in a variety of file formats.
#### Padding numbers
If you need to pad a number, you can use this JavaScript code around the variable. For example to pad the `FILE_EXPORT_COUNT` variable to 2 digits with leading zeros: `${String(FILE_EXPORT_COUNT).padStart(2, '0')}`
If you need more help, you can ask an AI to help you with this, e.g. "How to pad a number with JavaScript?"
## Import/export projects
See [list of supported formats](https://github.com/mifi/lossless-cut/issues/1340).
LosslessCut also allows importing/exporting your project (segments) in a variety of file formats. See [list of supported formats](https://github.com/mifi/lossless-cut/issues/1340).
### CSV

@ -142,6 +142,7 @@ function App() {
const [hideMediaSourcePlayer, setHideMediaSourcePlayer] = useState(false);
const [exportConfirmVisible, setExportConfirmVisible] = useState(false);
const [cacheBuster, setCacheBuster] = useState(0);
const [currentFileExportCount, setCurrentFileExportCount] = useState(0);
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
@ -162,6 +163,7 @@ function App() {
const [editingSegmentTags, setEditingSegmentTags] = useState<SegmentTags>();
const [mediaSourceQuality, setMediaSourceQuality] = useState(0);
const [smartCutBitrate, setSmartCutBitrate] = useState<number | undefined>();
const [exportCount, setExportCount] = useState(0);
const incrementMediaSourceQuality = useCallback(() => setMediaSourceQuality((v) => (v + 1) % mediaSourceQualities.length), []);
@ -581,6 +583,7 @@ function App() {
setHideMediaSourcePlayer(false);
setExportConfirmVisible(false);
setOutputPlaybackRateState(1);
setCurrentFileExportCount(0);
}, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setDuration, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, setOutputPlaybackRateState]);
@ -950,13 +953,13 @@ function App() {
const generateOutSegFileNames = useCallback(async ({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
invariant(fileFormat != null && outputDir != null && filePath != null);
return generateOutSegFileNamesRaw({ segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding });
}, [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);
return generateOutSegFileNamesRaw({ exportCount, currentFileExportCount, segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding });
}, [currentFileExportCount, exportCount, fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);
const generateMergedFileNames = useCallback(async ({ template }: { template: string }) => {
invariant(fileFormat != null && filePath != null);
return generateMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName });
}, [fileFormat, filePath, isCustomFormatSelected, outputDir, safeOutputFileName]);
return generateMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, exportCount, currentFileExportCount });
}, [currentFileExportCount, exportCount, fileFormat, filePath, isCustomFormatSelected, outputDir, safeOutputFileName]);
const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []);
@ -1103,6 +1106,9 @@ function App() {
}
if (cleanupChoices.cleanupAfterExport) await cleanupFilesWithDialog();
setExportCount((c) => c + 1);
setCurrentFileExportCount((c) => c + 1);
} catch (err) {
if (isAbortedError(err)) return;
@ -2647,7 +2653,7 @@ function App() {
/>
</Sheet>
<ConcatDialog isShown={batchFiles.length > 0 && concatDialogVisible} onHide={() => setConcatDialogVisible(false)} paths={batchFilePaths} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} />
<ConcatDialog isShown={batchFiles.length > 0 && concatDialogVisible} onHide={() => setConcatDialogVisible(false)} paths={batchFilePaths} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} exportCount={exportCount} />
<KeyboardShortcuts isShown={keyboardShortcutsVisible} onHide={() => setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} />
</div>

@ -33,8 +33,14 @@ function Alert({ text }: { text: string }) {
);
}
function ConcatDialog({ 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<void>, alwaysConcatMultipleFiles: boolean, setAlwaysConcatMultipleFiles: (a: boolean) => void,
function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles, exportCount }: {
isShown: boolean,
onHide: () => void,
paths: string[],
onConcat: (a: { paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], outFileName: string, fileFormat: string, clearBatchFilesAfterConcat: boolean }) => Promise<void>,
alwaysConcatMultipleFiles: boolean,
setAlwaysConcatMultipleFiles: (a: boolean) => void,
exportCount: number,
}) {
const { t } = useTranslation();
const { preserveMovData, setPreserveMovData, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, safeOutputFileName, customOutDir } = useUserSettings();
@ -93,7 +99,7 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi
// todo allow user to edit template instead of this "hack"
if (existingOutputName == null) {
(async () => {
const generated = await generateMergedFileNames({ template: defaultMergedFileTemplate, isCustomFormatSelected, fileFormat, filePath: firstPath, outputDir, safeOutputFileName, epochMs: uniqueSuffix });
const generated = await generateMergedFileNames({ template: defaultMergedFileTemplate, isCustomFormatSelected, fileFormat, filePath: firstPath, outputDir, safeOutputFileName, epochMs: uniqueSuffix, exportCount });
// todo show to user more errors?
const [fileName] = generated.fileNames;
invariant(fileName != null);
@ -106,7 +112,7 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi
// make sure the last (optional) .* is replaced by .ext`
return existingOutputName.replace(/(\.[^.]*)?$/, ext);
});
}, [customOutDir, fileFormat, firstPath, isCustomFormatSelected, safeOutputFileName, uniqueSuffix]);
}, [customOutDir, exportCount, fileFormat, firstPath, isCustomFormatSelected, safeOutputFileName, uniqueSuffix]);
const allFilesMeta = useMemo(() => {
if (paths.length === 0) return undefined;

@ -77,8 +77,8 @@ function FileNameTemplateEditor(opts: {
}, [debouncedText, generateFileNames, t]);
const availableVariables = useMemo(() => (mergeMode
? ['FILENAME', extVariable, 'EPOCH_MS']
: ['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, segNumIntVariable, 'SEG_LABEL', segSuffixVariable, extVariable, segTagsExample, 'EPOCH_MS']
? ['FILENAME', extVariable, 'EPOCH_MS', 'EXPORT_COUNT']
: ['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, segNumIntVariable, 'SEG_LABEL', segSuffixVariable, extVariable, segTagsExample, 'EPOCH_MS', 'EXPORT_COUNT', 'FILE_EXPORT_COUNT']
), [mergeMode]);
// eslint-disable-next-line no-template-curly-in-string

@ -111,10 +111,12 @@ export const defaultCutMergedFileTemplate = '${FILENAME}-cut-merged-${EPOCH_MS}$
// eslint-disable-next-line no-template-curly-in-string
export const defaultMergedFileTemplate = '${FILENAME}-merged-${EPOCH_MS}${EXT}';
async function interpolateOutFileName(template: string, { epochMs, inputFileNameWithoutExt, ext, segSuffix, segNum, segNumPadded, segLabel, cutFrom, cutTo, tags }: {
async function interpolateOutFileName(template: string, { epochMs, inputFileNameWithoutExt, ext, segSuffix, segNum, segNumPadded, segLabel, cutFrom, cutTo, tags, exportCount, currentFileExportCount }: {
epochMs: number,
inputFileNameWithoutExt: string,
ext: string,
exportCount: number,
currentFileExportCount?: number | undefined,
} & Partial<{
segSuffix: string,
segNum: number,
@ -139,6 +141,8 @@ async function interpolateOutFileName(template: string, { epochMs, inputFileName
...tags,
...Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key.toLocaleUpperCase('en-US')}`, value])),
},
FILE_EXPORT_COUNT: currentFileExportCount,
EXPORT_COUNT: exportCount,
};
const ret = (await safeishEval(`\`${template}\``, context));
@ -160,7 +164,7 @@ function maybeTruncatePath(fileName: string, truncate: boolean) {
].join(pathSep);
}
export async function generateOutSegFileNames({ segments, template: desiredTemplate, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }: {
export async function generateOutSegFileNames({ segments, template: desiredTemplate, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding, exportCount, currentFileExportCount }: {
segments: SegmentToExport[],
template: string,
formatTimecode: FormatTimecode,
@ -171,6 +175,8 @@ export async function generateOutSegFileNames({ segments, template: desiredTempl
safeOutputFileName: boolean,
maxLabelLength: number,
outputFileNameMinZeroPadding: number,
exportCount: number,
currentFileExportCount: number,
}) {
async function generate({ template, forceSafeOutputFileName }: { template: string, forceSafeOutputFileName: boolean }) {
const epochMs = Date.now();
@ -204,6 +210,8 @@ export async function generateOutSegFileNames({ segments, template: desiredTempl
cutFrom: formatTimecode({ seconds: start, fileNameFriendly: true }),
cutTo: formatTimecode({ seconds: end, fileNameFriendly: true }),
tags: Object.fromEntries(Object.entries(getSegmentTags(segment)).map(([tag, value]) => [tag, filenamifyOrNot(value)])),
exportCount,
currentFileExportCount,
});
return maybeTruncatePath(segFileName, safeOutputFileName);
@ -228,7 +236,7 @@ export type GenerateOutFileNames = (a: { template: string }) => Promise<{
},
}>;
export async function generateMergedFileNames({ template: desiredTemplate, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, epochMs = Date.now() }: {
export async function generateMergedFileNames({ template: desiredTemplate, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, epochMs = Date.now(), exportCount, currentFileExportCount }: {
template: string,
isCustomFormatSelected: boolean,
fileFormat: string,
@ -236,6 +244,8 @@ export async function generateMergedFileNames({ template: desiredTemplate, isCus
outputDir: string,
safeOutputFileName: boolean,
epochMs?: number,
exportCount: number,
currentFileExportCount?: number,
}) {
async function generate(template: string) {
const { name: inputFileNameWithoutExt } = parsePath(filePath);
@ -244,6 +254,8 @@ export async function generateMergedFileNames({ template: desiredTemplate, isCus
epochMs,
inputFileNameWithoutExt,
ext: getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath }),
exportCount,
currentFileExportCount,
});
return maybeTruncatePath(fileName, safeOutputFileName);

Loading…
Cancel
Save