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:
parent
d4daa8a90b
commit
8f4a309038
39
src/App.jsx
39
src/App.jsx
@ -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,
|
||||
|
@ -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" />
|
||||
|
@ -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 });
|
||||
|
24
src/util.js
24
src/util.js
@ -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
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user