mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-21 18:02:35 +01:00
parent
54244d2317
commit
b806b9f5b6
@ -2,19 +2,20 @@
|
||||
|
||||
## 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. The following variables are available to customize the filenames:
|
||||
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:
|
||||
|
||||
| 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`)
|
||||
| `${SEG_NUM}` | Number of the segment (e.g. `1`, `2` or `3`)
|
||||
| `${EPOCH_MS}` | Number of milliseconds since epoch (e.g. `1680852771465`)
|
||||
| `${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}`
|
||||
| `${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`).
|
||||
| `${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}`.
|
||||
| `${EPOCH_MS}` | Number of milliseconds since epoch (e.g. `1680852771465`).
|
||||
| `${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 they 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}`
|
||||
|
||||
|
@ -1208,7 +1208,7 @@ function App() {
|
||||
}
|
||||
}, [cleanupFilesWithDialog, isFileOpened, setWorking]);
|
||||
|
||||
const generateOutSegFileNames = useCallback(({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
|
||||
const generateOutSegFileNames = useCallback(async ({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
|
||||
if (fileFormat == null || outputDir == null || filePath == null) throw new Error();
|
||||
return generateOutSegFileNamesRaw({ segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding });
|
||||
}, [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);
|
||||
@ -1258,7 +1258,7 @@ function App() {
|
||||
|
||||
console.log('outSegTemplateOrDefault', outSegTemplateOrDefault);
|
||||
|
||||
const { outSegFileNames, outSegProblems } = generateOutSegFileNames({ segments: segmentsToExport, template: outSegTemplateOrDefault });
|
||||
const { outSegFileNames, outSegProblems } = await generateOutSegFileNames({ segments: segmentsToExport, template: outSegTemplateOrDefault });
|
||||
if (outSegProblems.error != null) {
|
||||
console.warn('Output segments file name invalid, using default instead', outSegFileNames);
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { ReactSwal } from '../swal';
|
||||
import HighlightedText from './HighlightedText';
|
||||
import { defaultOutSegTemplate, segNumVariable, segSuffixVariable, GenerateOutSegFileNames } from '../util/outputNameTemplate';
|
||||
import { defaultOutSegTemplate, segNumVariable, segSuffixVariable, GenerateOutSegFileNames, extVariable, segTagsVariable, segNumIntVariable } from '../util/outputNameTemplate';
|
||||
import useUserSettings from '../hooks/useUserSettings';
|
||||
import Switch from './Switch';
|
||||
import Select from './Select';
|
||||
@ -18,7 +18,8 @@ const electron = window.require('electron');
|
||||
|
||||
const formatVariable = (variable) => `\${${variable}}`;
|
||||
|
||||
const extVar = formatVariable('EXT');
|
||||
const extVariableFormatted = formatVariable(extVariable);
|
||||
const segTagsExample = `${segTagsVariable}.XX`;
|
||||
|
||||
function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe }: {
|
||||
outSegTemplate: string, setOutSegTemplate: (text: string) => void, generateOutSegFileNames: GenerateOutSegFileNames, currentSegIndexSafe: number,
|
||||
@ -38,22 +39,33 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
||||
const hasTextNumericPaddedValue = useMemo(() => [segNumVariable, segSuffixVariable].some((v) => debouncedText.includes(formatVariable(v))), [debouncedText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedText == null) return;
|
||||
|
||||
try {
|
||||
const outSegs = generateOutSegFileNames({ template: debouncedText });
|
||||
setOutSegFileNames(outSegs.outSegFileNames);
|
||||
setOutSegProblems(outSegs.outSegProblems);
|
||||
setValidText(outSegs.outSegProblems.error == null ? debouncedText : undefined);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setValidText(undefined);
|
||||
setOutSegProblems({ error: err instanceof Error ? err.message : String(err) });
|
||||
if (debouncedText == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// console.time('generateOutSegFileNames')
|
||||
const outSegs = await generateOutSegFileNames({ template: debouncedText });
|
||||
// console.timeEnd('generateOutSegFileNames')
|
||||
if (abortController.signal.aborted) return;
|
||||
setOutSegFileNames(outSegs.outSegFileNames);
|
||||
setOutSegProblems(outSegs.outSegProblems);
|
||||
setValidText(outSegs.outSegProblems.error == null ? debouncedText : undefined);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setValidText(undefined);
|
||||
setOutSegProblems({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [debouncedText, generateOutSegFileNames, t]);
|
||||
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
const isMissingExtension = validText != null && !validText.endsWith(extVar);
|
||||
const isMissingExtension = validText != null && !validText.endsWith(extVariableFormatted);
|
||||
|
||||
const onAllSegmentsPreviewPress = useCallback(() => {
|
||||
if (outSegFileNames == null) return;
|
||||
@ -89,13 +101,15 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
||||
const gotImportantMessage = outSegProblems.error != null || outSegProblems.sameAsInputFileNameWarning;
|
||||
const needToShow = shown || gotImportantMessage;
|
||||
|
||||
const onVariableClick = useCallback((variable) => {
|
||||
const onVariableClick = useCallback((variable: string) => {
|
||||
const input = inputRef.current;
|
||||
const startPos = input!.selectionStart;
|
||||
const endPos = input!.selectionEnd;
|
||||
if (startPos == null || endPos == null) return;
|
||||
|
||||
const newValue = `${text.slice(0, startPos)}${`${formatVariable(variable)}${text.slice(endPos)}`}`;
|
||||
const toInsert = variable === segTagsExample ? `${segTagsExample} ?? ''` : variable;
|
||||
|
||||
const newValue = `${text.slice(0, startPos)}${`${formatVariable(toInsert)}${text.slice(endPos)}`}`;
|
||||
setText(newValue);
|
||||
}, [text]);
|
||||
|
||||
@ -126,7 +140,7 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
||||
{`${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) => (
|
||||
{['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, segNumIntVariable, 'SEG_LABEL', segSuffixVariable, extVariable, segTagsExample, '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>
|
||||
@ -147,7 +161,7 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
||||
{isMissingExtension && (
|
||||
<div style={{ marginBottom: '1em' }}>
|
||||
<WarningSignIcon verticalAlign="middle" 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 })}
|
||||
{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: extVariableFormatted })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -261,7 +261,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, minLength = 0) {
|
||||
export function formatSegNum(segIndex: number, numSegments: number, minLength = 0) {
|
||||
const numDigits = getNumDigits(numSegments);
|
||||
return `${segIndex + 1}`.padStart(Math.max(numDigits, minLength), '0');
|
||||
}
|
||||
|
@ -1,15 +1,19 @@
|
||||
import i18n from 'i18next';
|
||||
import lodashTemplate from 'lodash/template';
|
||||
import { PlatformPath } from 'node:path';
|
||||
import pMap from 'p-map';
|
||||
|
||||
import { isMac, isWindows, hasDuplicates, filenamify, getOutFileExtension } from '../util';
|
||||
import isDev from '../isDev';
|
||||
import { getSegmentTags, formatSegNum } from '../segments';
|
||||
import { FormatTimecode, SegmentToExport } from '../types';
|
||||
import safeishEval from '../worker/eval';
|
||||
|
||||
|
||||
export const segNumVariable = 'SEG_NUM';
|
||||
export const segNumIntVariable = 'SEG_NUM_INT';
|
||||
export const segSuffixVariable = 'SEG_SUFFIX';
|
||||
export const extVariable = 'EXT';
|
||||
export const segTagsVariable = 'SEG_TAGS';
|
||||
|
||||
const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename }: PlatformPath = window.require('path');
|
||||
|
||||
@ -97,36 +101,41 @@ function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}';
|
||||
|
||||
function interpolateSegmentFileName({ template, epochMs, inputFileNameWithoutExt, segSuffix, ext, segNum, segLabel, cutFrom, cutTo, tags }) {
|
||||
const compiled = lodashTemplate(template);
|
||||
|
||||
const data = {
|
||||
async function interpolateSegmentFileName({ template, epochMs, inputFileNameWithoutExt, segSuffix, ext, segNum, segNumPadded, segLabel, cutFrom, cutTo, tags }: {
|
||||
template: string, epochMs: number, inputFileNameWithoutExt: string, segSuffix: string, ext: string, segNum: number, segNumPadded: string, segLabel: string, cutFrom: string, cutTo: string, tags: Record<string, string>
|
||||
}) {
|
||||
const context = {
|
||||
FILENAME: inputFileNameWithoutExt,
|
||||
SEG_SUFFIX: segSuffix,
|
||||
EXT: ext,
|
||||
SEG_NUM: segNum,
|
||||
[segSuffixVariable]: segSuffix,
|
||||
[extVariable]: ext,
|
||||
[segNumIntVariable]: segNum,
|
||||
[segNumVariable]: segNumPadded, // todo rename this (breaking change)
|
||||
SEG_LABEL: segLabel,
|
||||
EPOCH_MS: String(epochMs),
|
||||
EPOCH_MS: epochMs,
|
||||
CUT_FROM: cutFrom,
|
||||
CUT_TO: cutTo,
|
||||
SEG_TAGS: {
|
||||
[segTagsVariable]: {
|
||||
// allow both original case and uppercase
|
||||
...tags,
|
||||
...Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key.toLocaleUpperCase('en-US')}`, value])),
|
||||
},
|
||||
};
|
||||
return compiled(data);
|
||||
|
||||
const ret = (await safeishEval(`\`${template}\``, context));
|
||||
if (typeof ret !== 'string') throw new Error('Expression did not lead to a string');
|
||||
return ret;
|
||||
}
|
||||
|
||||
export 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 }: {
|
||||
segments: SegmentToExport[], template: string, formatTimecode: FormatTimecode, isCustomFormatSelected: boolean, fileFormat: string, filePath: string, outputDir: string, safeOutputFileName: boolean, maxLabelLength: number, outputFileNameMinZeroPadding: number,
|
||||
}) {
|
||||
function generate({ template, forceSafeOutputFileName }: { template: string, forceSafeOutputFileName: boolean }) {
|
||||
const epochMs = Date.now();
|
||||
|
||||
return segments.map((segment, i) => {
|
||||
return pMap(segments, async (segment, i) => {
|
||||
const { start, end, name = '' } = segment;
|
||||
const segNum = formatSegNum(i, segments.length, outputFileNameMinZeroPadding);
|
||||
const segNum = i + 1;
|
||||
const segNumPadded = 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)
|
||||
@ -135,16 +144,17 @@ export function generateOutSegFileNames({ segments, template: desiredTemplate, f
|
||||
function getSegSuffix() {
|
||||
if (name) return `-${filenamifyOrNot(name)}`;
|
||||
// https://github.com/mifi/lossless-cut/issues/583
|
||||
if (segments.length > 1) return `-seg${segNum}`;
|
||||
if (segments.length > 1) return `-seg${segNumPadded}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
const { name: inputFileNameWithoutExt } = parsePath(filePath);
|
||||
|
||||
const segFileName = interpolateSegmentFileName({
|
||||
const segFileName = await interpolateSegmentFileName({
|
||||
template,
|
||||
epochMs,
|
||||
segNum,
|
||||
segNumPadded,
|
||||
inputFileNameWithoutExt,
|
||||
segSuffix: getSegSuffix(),
|
||||
ext: getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath }),
|
||||
@ -165,14 +175,14 @@ export function generateOutSegFileNames({ segments, template: desiredTemplate, f
|
||||
// If sanitation is enabled, make sure filename (last seg of the path) is not too long
|
||||
safeOutputFileName ? lastSeg!.slice(0, 200) : lastSeg,
|
||||
].join(pathSep);
|
||||
});
|
||||
}, { concurrency: 5 });
|
||||
}
|
||||
|
||||
let outSegFileNames = generate({ template: desiredTemplate, forceSafeOutputFileName: false });
|
||||
let outSegFileNames = await generate({ template: desiredTemplate, forceSafeOutputFileName: false });
|
||||
|
||||
const outSegProblems = getOutSegProblems({ fileNames: outSegFileNames, filePath, outputDir, safeOutputFileName });
|
||||
if (outSegProblems.error != null) {
|
||||
outSegFileNames = generate({ template: defaultOutSegTemplate, forceSafeOutputFileName: true });
|
||||
outSegFileNames = await generate({ template: defaultOutSegTemplate, forceSafeOutputFileName: true });
|
||||
}
|
||||
|
||||
return { outSegFileNames, outSegProblems };
|
||||
|
@ -6,7 +6,7 @@ const worker = new Worker(workerUrl);
|
||||
|
||||
let lastRequestId = 0;
|
||||
|
||||
export default async function safeishEval(code: string, context: unknown) {
|
||||
export default async function safeishEval(code: string, context: Record<string, unknown>) {
|
||||
return new Promise((resolve, reject) => {
|
||||
lastRequestId += 1;
|
||||
const id = lastRequestId;
|
||||
|
Loading…
Reference in New Issue
Block a user