1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-25 11:43:17 +01:00

allow slashes in output files

allows for creating custom directory structures based on labels etc
closes #1532
This commit is contained in:
Mikael Finstad 2023-04-02 22:31:35 +09:00
parent d4daa8a90b
commit 8f4a309038
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
5 changed files with 113 additions and 79 deletions

View File

@ -64,9 +64,8 @@ import { formatYouTube, getFrameCountRaw } from './edlFormats';
import {
getOutPath, getSuffixedOutPath, handleError, getOutDir,
isMasBuild, isStoreBuild, dragPreventer,
filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate,
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
deleteFiles, isOutOfSpaceError, getNumDigits, isExecaFailure, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle,
deleteFiles, isOutOfSpaceError, isExecaFailure, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle,
} from './util';
import { toast, errorToast } from './swal';
import { formatDuration } from './util/duration';
@ -76,8 +75,8 @@ import { askForHtml5ifySpeed } from './dialogs/html5ify';
import { askForOutDir, askForImportChapters, promptTimeOffset, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openCutFinishedToast, openConcatFinishedToast, showOpenDialog } from './dialogs';
import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n';
import { createSegment, getCleanCutSegments, findSegmentsAtCursor, sortSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment } from './segments';
import { getOutSegError as getOutSegErrorRaw } from './util/outputNameTemplate';
import { createSegment, getCleanCutSegments, findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment } from './segments';
import { getOutSegError as getOutSegErrorRaw, generateOutSegFileNames as generateOutSegFileNamesRaw, defaultOutSegTemplate } from './util/outputNameTemplate';
import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants';
import BigWaveform from './components/BigWaveform';
@ -1048,34 +1047,10 @@ const App = memo(() => {
}, [isFileOpened, cleanupChoices, askForCleanupChoices, cleanupFiles, setWorking]);
const generateOutSegFileNames = useCallback(({ segments = segmentsToExport, template, forceSafeOutputFileName }) => (
segments.map((segment, i) => {
const { start, end, name = '' } = segment;
const cutFromStr = formatTimecode({ seconds: start, fileNameFriendly: true });
const cutToStr = formatTimecode({ seconds: end, fileNameFriendly: true });
const numDigits = getNumDigits(segments.length);
const segNum = `${i + 1}`.padStart(numDigits, '0');
generateOutSegFileNamesRaw({ segments, template, forceSafeOutputFileName, formatTimecode, isCustomFormatSelected, fileFormat, filePath, safeOutputFileName, maxLabelLength })
), [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, safeOutputFileName, segmentsToExport]);
const filenamifyOrNot = (fileName) => (safeOutputFileName || forceSafeOutputFileName ? filenamify(fileName) : fileName).substr(0, maxLabelLength);
let segSuffix = '';
if (name) segSuffix = `-${filenamifyOrNot(name)}`;
// https://github.com/mifi/lossless-cut/issues/583
else if (segments.length > 1) segSuffix = `-seg${segNum}`;
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath });
const { name: fileNameWithoutExt } = parsePath(filePath);
// 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
const tagsSanitized = Object.fromEntries(Object.entries(getSegmentTags(segment)).map(([tag, value]) => [tag, filenamifyOrNot(value)]));
const nameSanitized = filenamifyOrNot(name);
const generated = generateSegFileName({ template, segSuffix, inputFileNameWithoutExt: fileNameWithoutExt, ext, segNum, segLabel: nameSanitized, cutFrom: cutFromStr, cutTo: cutToStr, tags: tagsSanitized });
return safeOutputFileName ? generated.substring(0, 200) : generated; // If sanitation is enabled, make sure filename is not too long
})
), [segmentsToExport, formatTimecode, isCustomFormatSelected, fileFormat, filePath, safeOutputFileName, maxLabelLength]);
const getOutSegError = useCallback((fileNames) => getOutSegErrorRaw({ fileNames, filePath, outputDir }), [outputDir, filePath]);
const getOutSegError = useCallback((fileNames) => getOutSegErrorRaw({ fileNames, filePath, outputDir, safeOutputFileName }), [filePath, outputDir, safeOutputFileName]);
const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []);
@ -1124,7 +1099,7 @@ const App = memo(() => {
allFilesMeta,
keyframeCut,
segments: segmentsToExport,
segmentsFileNames: outSegFileNames,
outSegFileNames,
onProgress: setCutProgress,
appendFfmpegCommandLog,
shortestFlag,

View File

@ -7,7 +7,7 @@ import withReactContent from 'sweetalert2-react-content';
import Swal from '../swal';
import HighlightedText from './HighlightedText';
import { defaultOutSegTemplate } from '../util';
import { defaultOutSegTemplate } from '../util/outputNameTemplate';
import useUserSettings from '../hooks/useUserSettings';
const ReactSwal = withReactContent(Swal);
@ -33,9 +33,9 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
if (debouncedText == null) return;
try {
const generatedOutSegFileNames = generateOutSegFileNames({ template: debouncedText });
setOutSegFileNames(generatedOutSegFileNames);
const outSegError = getOutSegError(generatedOutSegFileNames);
const newOutSegFileNames = generateOutSegFileNames({ template: debouncedText });
setOutSegFileNames(newOutSegFileNames);
const outSegError = getOutSegError(newOutSegFileNames);
if (outSegError) {
setError(outSegError);
setValidText();
@ -96,7 +96,7 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 5, marginTop: 10 }}>
<input type="text" style={inputStyle} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" />
{outSegFileNames && <Button height={20} onClick={onAllSegmentsPreviewPress} marginLeft={5}>{t('Preview')}</Button>}
{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" />

View File

@ -8,8 +8,9 @@ import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getDura
import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams';
import { getSmartCutParams } from '../smartcut';
const { join, resolve } = window.require('path');
const fs = window.require('fs-extra');
const { join, resolve, dirname } = window.require('path');
const { pathExists } = window.require('fs-extra');
const { writeFile, unlink, mkdir } = window.require('fs/promises');
const stringToStream = window.require('string-to-stream');
async function writeChaptersFfmetadata(outDir, chapters) {
@ -21,7 +22,7 @@ async function writeChaptersFfmetadata(outDir, chapters) {
`[CHAPTER]\nTIMEBASE=1/1000\nSTART=${Math.floor(start * 1000)}\nEND=${Math.floor(end * 1000)}\ntitle=${name || ''}`
)).join('\n\n');
console.log('Writing chapters', ffmetadata);
await fs.writeFile(path, ffmetadata);
await writeFile(path, ffmetadata);
return path;
}
@ -52,7 +53,7 @@ function getMatroskaFlags() {
const getChaptersInputArgs = (ffmetadataPath) => (ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []);
const tryDeleteFiles = async (paths) => pMap(paths, (path) => {
fs.unlink(path).catch((err) => console.error('Failed to delete', path, err));
unlink(path).catch((err) => console.error('Failed to delete', path, err));
}, { concurrency: 5 });
function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut }) {
@ -321,7 +322,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
}, [filePath, optionalTransferTimestamps]);
const cutMultiple = useCallback(async ({
outputDir, customOutDir, segments, segmentsFileNames, videoDuration, rotation, detectedFps,
outputDir, customOutDir, segments, outSegFileNames, videoDuration, rotation, detectedFps,
onProgress: onTotalProgress, keyframeCut, copyFileStreams, allFilesMeta, outFormat,
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs,
customTagsByFile, customTagsByStreamId, dispositionByStreamId, chapters, preserveMetadataOnMerge,
@ -339,7 +340,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
const chaptersPath = await writeChaptersFfmetadata(outputDir, chapters);
async function checkOverwrite(path) {
if (!enableOverwriteOutput && await fs.pathExists(path)) throw new RefuseOverwriteError();
if (!enableOverwriteOutput && await pathExists(path)) throw new RefuseOverwriteError();
}
@ -348,11 +349,17 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
// then it will cut the part *from* the keyframe to "end", and concat them together and return the concated file
// so that for the calling code it looks as if it's just a normal segment
async function maybeSmartCutSegment({ start: desiredCutFrom, end: cutTo }, i) {
const getSegmentOutPath = () => join(outputDir, segmentsFileNames[i]);
async function makeSegmentOutPath() {
const outPath = join(outputDir, outSegFileNames[i]);
// because outSegFileNames might contain slashes https://github.com/mifi/lossless-cut/issues/1532
const actualOutputDir = dirname(outPath);
if (actualOutputDir !== outputDir) await mkdir(actualOutputDir, { recursive: true });
return outPath;
}
if (!needSmartCut) {
// old fashioned way
const outPath = getSegmentOutPath();
const outPath = await makeSegmentOutPath();
await checkOverwrite(outPath);
await cutSingle({
cutFrom: desiredCutFrom, cutTo, chaptersPath, outPath, copyFileStreams, keyframeCut, avoidNegativeTs, videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, customTagsByStreamId, dispositionByStreamId, onProgress: (progress) => onSingleProgress(i, progress),
@ -386,7 +393,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
// If we are cutting within two keyframes, just encode the whole part and return that
// See https://github.com/mifi/lossless-cut/pull/1267#issuecomment-1236381740
if (segmentNeedsSmartCut && encodeCutTo > cutTo) {
const outPath = getSegmentOutPath();
const outPath = await makeSegmentOutPath();
await checkOverwrite(outPath);
await cutEncodeSmartPartWrapper({ cutFrom: desiredCutFrom, cutTo, outPath });
return outPath;
@ -396,7 +403,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
const smartCutMainPartOutPath = segmentNeedsSmartCut
? getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-copy-${i}${ext}` })
: getSegmentOutPath();
: await makeSegmentOutPath();
const smartCutEncodedPartOutPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-encode-${i}${ext}` });
@ -421,7 +428,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps, needSmartCut
// need to re-read streams because indexes may have changed. Using main file as source of streams and metadata
const { streams: streamsAfterCut } = await readFileMeta(smartCutMainPartOutPath);
const outPath = getSegmentOutPath();
const outPath = await makeSegmentOutPath();
await checkOverwrite(outPath);
await concatFiles({ paths: smartCutSegmentsToConcat, outDir: outputDir, outPath, metadataFromPath: smartCutMainPartOutPath, outFormat, includeAllStreams: true, streams: streamsAfterCut, ffmpegExperimental, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase, appendFfmpegCommandLog, onProgress: onConcatProgress });

View File

@ -1,5 +1,4 @@
import i18n from 'i18next';
import lodashTemplate from 'lodash/template';
import pMap from 'p-map';
import ky from 'ky';
import prettyBytes from 'pretty-bytes';
@ -167,29 +166,6 @@ export function getOutFileExtension({ isCustomFormatSelected, outFormat, filePat
return `.${getExtensionForFormat(outFormat)}`;
}
// This is used as a fallback and so it has to always generate unique file names
// eslint-disable-next-line no-template-curly-in-string
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}';
export function generateSegFileName({ template, inputFileNameWithoutExt, segSuffix, ext, segNum, segLabel, cutFrom, cutTo, tags }) {
const compiled = lodashTemplate(template);
const data = {
FILENAME: inputFileNameWithoutExt,
SEG_SUFFIX: segSuffix,
EXT: ext,
SEG_NUM: segNum,
SEG_LABEL: segLabel,
CUT_FROM: cutFrom,
CUT_TO: cutTo,
SEG_TAGS: {
// allow both original case and uppercase
...tags,
...Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key.toLocaleUpperCase('en-US')}`, value])),
},
};
return compiled(data);
}
export const hasDuplicates = (arr) => new Set(arr).size !== arr.length;
// Need to resolve relative paths from the command line https://github.com/mifi/lossless-cut/issues/639

View File

@ -1,12 +1,15 @@
import i18n from 'i18next';
import lodashTemplate from 'lodash/template';
import { isMac, isWindows, hasDuplicates } from '../util';
import { isMac, isWindows, hasDuplicates, filenamify, getOutFileExtension, getNumDigits } from '../util';
import isDev from '../isDev';
import { getSegmentTags } from '../segments';
const { sep: pathSep, join: pathJoin, normalize: pathNormalize } = window.require('path');
const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize } = window.require('path');
// eslint-disable-next-line import/prefer-default-export
export function getOutSegError({ fileNames, filePath, outputDir }) {
export function getOutSegError({ fileNames, filePath, outputDir, safeOutputFileName }) {
let error;
// eslint-disable-next-line no-restricted-syntax
@ -19,15 +22,16 @@ export function getOutSegError({ fileNames, filePath, outputDir }) {
const invalidChars = new Set();
// https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names
// note that we allow path separators!
if (isWindows) {
['/', '<', '>', ':', '"', '\\', '|', '?', '*'].forEach((char) => invalidChars.add(char));
['<', '>', ':', '"', '|', '?', '*'].forEach((char) => invalidChars.add(char));
} else if (isMac) {
// Colon is invalid on windows https://github.com/mifi/lossless-cut/issues/631 and on MacOS, but not Linux https://github.com/mifi/lossless-cut/issues/830
['/', ':'].forEach((char) => invalidChars.add(char));
} else {
invalidChars.add(pathSep);
[':'].forEach((char) => invalidChars.add(char));
}
if (safeOutputFileName) invalidChars.add(pathSep);
const outPath = pathNormalize(pathJoin(outputDir, fileName));
const sameAsInputPath = outPath === pathNormalize(filePath);
const windowsMaxPathLength = 259;
@ -57,3 +61,75 @@ export function getOutSegError({ fileNames, filePath, outputDir }) {
return undefined;
}
// This is used as a fallback and so it has to always generate unique file names
// eslint-disable-next-line no-template-curly-in-string
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}';
function interpolateSegmentFileName({ template, inputFileNameWithoutExt, segSuffix, ext, segNum, segLabel, cutFrom, cutTo, tags }) {
const compiled = lodashTemplate(template);
const data = {
FILENAME: inputFileNameWithoutExt,
SEG_SUFFIX: segSuffix,
EXT: ext,
SEG_NUM: segNum,
SEG_LABEL: segLabel,
CUT_FROM: cutFrom,
CUT_TO: cutTo,
SEG_TAGS: {
// allow both original case and uppercase
...tags,
...Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key.toLocaleUpperCase('en-US')}`, value])),
},
};
return compiled(data);
}
function formatSegNum(segIndex, segments) {
const numDigits = getNumDigits(segments);
return `${segIndex + 1}`.padStart(numDigits, '0');
}
export function generateOutSegFileNames({ segments, template, forceSafeOutputFileName, formatTimecode, isCustomFormatSelected, fileFormat, filePath, safeOutputFileName, maxLabelLength }) {
return segments.map((segment, i) => {
const { start, end, name = '' } = segment;
const segNum = formatSegNum(i, segments);
// 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)
const filenamifyOrNot = (fileName) => (safeOutputFileName || forceSafeOutputFileName ? filenamify(fileName) : fileName).substr(0, maxLabelLength);
function getSegSuffix() {
if (name) return `-${filenamifyOrNot(name)}`;
// https://github.com/mifi/lossless-cut/issues/583
if (segments.length > 1) return `-seg${segNum}`;
return '';
}
const { name: inputFileNameWithoutExt } = parsePath(filePath);
const segFileName = interpolateSegmentFileName({
template,
segNum,
inputFileNameWithoutExt,
segSuffix: getSegSuffix(),
ext: getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath }),
segLabel: filenamifyOrNot(name),
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)])),
});
// Now split the path by its separator, so we can check the actual file name (last path seg)
const pathSegs = segFileName.split(pathSep);
if (pathSegs.length === 0) return '';
const [lastSeg] = pathSegs.slice(-1);
const rest = pathSegs.slice(0, -1);
return [
...rest,
// If sanitation is enabled, make sure filename (last seg of the path) is not too long
safeOutputFileName ? lastSeg.substring(0, 200) : lastSeg,
].join(pathSep);
});
}