mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-25 11:43:17 +01:00
imrpve types
This commit is contained in:
parent
daaa2c652d
commit
2e0b9887fd
@ -49,6 +49,7 @@
|
||||
"@tsconfig/vite-react": "^3.0.0",
|
||||
"@types/eslint": "^8",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "18",
|
||||
"@types/sortablejs": "^1.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
|
49
src/App.tsx
49
src/App.tsx
@ -85,7 +85,7 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti
|
||||
import BigWaveform from './components/BigWaveform';
|
||||
|
||||
import isDev from './isDev';
|
||||
import { EdlFileType, FfmpegCommandLog, FfprobeChapter, FfprobeFormat, FfprobeStream, Html5ifyMode, PlaybackMode, StateSegment, Thumbnail, TunerType } from './types';
|
||||
import { EdlFileType, FfmpegCommandLog, FfprobeChapter, FfprobeFormat, FfprobeStream, Html5ifyMode, PlaybackMode, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types';
|
||||
|
||||
const electron = window.require('electron');
|
||||
const { exists } = window.require('fs-extra');
|
||||
@ -121,7 +121,7 @@ function App() {
|
||||
const [rotation, setRotation] = useState(360);
|
||||
const [cutProgress, setCutProgress] = useState<number>();
|
||||
const [startTimeOffset, setStartTimeOffset] = useState(0);
|
||||
const [filePath, setFilePath] = useState('');
|
||||
const [filePath, setFilePath] = useState<string>();
|
||||
const [externalFilesMeta, setExternalFilesMeta] = useState({});
|
||||
const [customTagsByFile, setCustomTagsByFile] = useState({});
|
||||
const [paramsByStreamId, setParamsByStreamId] = useState(new Map());
|
||||
@ -444,7 +444,7 @@ function App() {
|
||||
const usingPreviewFile = !!previewFilePath;
|
||||
const effectiveFilePath = previewFilePath || filePath;
|
||||
const fileUri = useMemo(() => {
|
||||
if (!effectiveFilePath) return '';
|
||||
if (!effectiveFilePath) return ''; // Setting video src="" prevents memory leak in chromium
|
||||
const uri = filePathToUrl(effectiveFilePath);
|
||||
// https://github.com/mifi/lossless-cut/issues/1674
|
||||
if (cacheBuster !== 0) {
|
||||
@ -458,10 +458,10 @@ function App() {
|
||||
const projectSuffix = 'proj.llc';
|
||||
const oldProjectSuffix = 'llc-edl.csv';
|
||||
// New LLC format can be stored along with input file or in working dir (customOutDir)
|
||||
const getEdlFilePath = useCallback((fp: string, cod?: string) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: projectSuffix }), []);
|
||||
const getEdlFilePath = useCallback((fp?: string, cod?: string) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: projectSuffix }), []);
|
||||
// Old versions of LosslessCut used CSV files and stored them always in customOutDir:
|
||||
const getEdlFilePathOld = useCallback((fp, cod) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: oldProjectSuffix }), []);
|
||||
const getProjectFileSavePath = useCallback((storeProjectInWorkingDirIn) => getEdlFilePath(filePath, storeProjectInWorkingDirIn ? customOutDir : undefined), [getEdlFilePath, filePath, customOutDir]);
|
||||
const getProjectFileSavePath = useCallback((storeProjectInWorkingDirIn: boolean) => getEdlFilePath(filePath, storeProjectInWorkingDirIn ? customOutDir : undefined), [getEdlFilePath, filePath, customOutDir]);
|
||||
const projectFileSavePath = useMemo(() => getProjectFileSavePath(storeProjectInWorkingDir), [getProjectFileSavePath, storeProjectInWorkingDir]);
|
||||
|
||||
const currentSaveOperation = useMemo(() => {
|
||||
@ -664,7 +664,7 @@ function App() {
|
||||
|
||||
const allFilesMeta = useMemo(() => ({
|
||||
...externalFilesMeta,
|
||||
[filePath]: mainFileMeta,
|
||||
...(filePath ? { [filePath]: mainFileMeta } : {}),
|
||||
}), [externalFilesMeta, filePath, mainFileMeta]);
|
||||
|
||||
// total number of streams for ALL files
|
||||
@ -742,6 +742,7 @@ function App() {
|
||||
const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, shouldShowWaveform, ffmpegExtractWindow, durationSafe });
|
||||
|
||||
const resetMergedOutFileName = useCallback(() => {
|
||||
if (fileFormat == null || filePath == null) return;
|
||||
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath });
|
||||
const outFileName = getSuffixedFileName(filePath, `cut-merged-${Date.now()}${ext}`);
|
||||
setMergedOutFileName(outFileName);
|
||||
@ -770,7 +771,7 @@ function App() {
|
||||
setRotation(360);
|
||||
setCutProgress(undefined);
|
||||
setStartTimeOffset(0);
|
||||
setFilePath(''); // Setting video src="" prevents memory leak in chromium
|
||||
setFilePath(undefined);
|
||||
setExternalFilesMeta({});
|
||||
setCustomTagsByFile({});
|
||||
setParamsByStreamId(new Map());
|
||||
@ -1173,16 +1174,17 @@ function App() {
|
||||
}
|
||||
}, [cleanupFilesWithDialog, isFileOpened, setWorking]);
|
||||
|
||||
const generateOutSegFileNames = useCallback(({ segments = segmentsToExport, template }) => (
|
||||
generateOutSegFileNamesRaw({ segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding })
|
||||
), [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);
|
||||
const generateOutSegFileNames = useCallback(({ 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]);
|
||||
|
||||
const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []);
|
||||
|
||||
const willMerge = segmentsToExport.length > 1 && autoMerge;
|
||||
|
||||
const mergedOutFilePath = useMemo(() => (
|
||||
getOutPath({ customOutDir, filePath, fileName: mergedOutFileName })
|
||||
mergedOutFileName != null ? getOutPath({ customOutDir, filePath, fileName: mergedOutFileName }) : undefined
|
||||
), [customOutDir, filePath, mergedOutFileName]);
|
||||
|
||||
const onExportConfirm = useCallback(async () => {
|
||||
@ -1351,6 +1353,7 @@ function App() {
|
||||
try {
|
||||
const currentTime = getRelevantTime();
|
||||
const video = videoRef.current;
|
||||
if (video == null) throw new Error();
|
||||
const useFffmpeg = usingPreviewFile || captureFrameMethod === 'ffmpeg';
|
||||
const outPath = useFffmpeg
|
||||
? await captureFrameFromFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, quality: captureFrameQuality })
|
||||
@ -1376,10 +1379,10 @@ function App() {
|
||||
|
||||
setCutProgress(0);
|
||||
|
||||
let lastOutPath;
|
||||
let lastOutPath: string | undefined;
|
||||
let totalProgress = 0;
|
||||
|
||||
const onProgress = (progress) => {
|
||||
const onProgress = (progress: number) => {
|
||||
totalProgress += progress;
|
||||
setCutProgress(totalProgress / segments.length);
|
||||
};
|
||||
@ -1387,10 +1390,11 @@ function App() {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const segment of segments) {
|
||||
const { start, end } = segment;
|
||||
if (filePath == null) throw new Error();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
lastOutPath = await captureFramesRange({ customOutDir, filePath, fps: detectedFps, fromTime: start, toTime: end, estimatedMaxNumFiles: captureFramesResponse.estimatedMaxNumFiles, captureFormat, quality: captureFrameQuality, filter: captureFramesResponse.filter, outputTimestamps: captureFrameFileNameFormat === 'timestamp', onProgress });
|
||||
}
|
||||
if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: lastOutPath, text: i18n.t('Frames extracted to: {{path}}', { path: outputDir }) });
|
||||
if (!hideAllNotifications && lastOutPath != null) openDirToast({ icon: 'success', filePath: lastOutPath, text: i18n.t('Frames extracted to: {{path}}', { path: outputDir }) });
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
} finally {
|
||||
@ -1693,8 +1697,8 @@ function App() {
|
||||
try {
|
||||
setWorking({ text: i18n.t('Extracting all streams') });
|
||||
setStreamsSelectorShown(false);
|
||||
const extractedPaths = await extractStreams({ customOutDir, filePath, streams: mainCopiedStreams, enableOverwriteOutput });
|
||||
if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: extractedPaths[0], text: i18n.t('All streams have been extracted as separate files') });
|
||||
const [firstExtractedPath] = await extractStreams({ customOutDir, filePath, streams: mainCopiedStreams, enableOverwriteOutput });
|
||||
if (!hideAllNotifications && firstExtractedPath != null) openDirToast({ icon: 'success', filePath: firstExtractedPath, text: i18n.t('All streams have been extracted as separate files') });
|
||||
} catch (err) {
|
||||
if (err instanceof RefuseOverwriteError) {
|
||||
showRefuseToOverwrite();
|
||||
@ -1982,8 +1986,9 @@ function App() {
|
||||
const showIncludeExternalStreamsDialog = useCallback(async () => {
|
||||
try {
|
||||
const { canceled, filePaths } = await showOpenDialog({ properties: ['openFile'] });
|
||||
if (canceled || filePaths.length === 0) return;
|
||||
await addStreamSourceFile(filePaths[0]);
|
||||
const [firstFilePath] = filePaths;
|
||||
if (canceled || firstFilePath == null) return;
|
||||
await addStreamSourceFile(firstFilePath);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
@ -2008,7 +2013,9 @@ function App() {
|
||||
|
||||
const onEditSegmentTags = useCallback((index: number) => {
|
||||
setEditingSegmentTagsSegmentIndex(index);
|
||||
setEditingSegmentTags(getSegmentTags(apparentCutSegments[index]));
|
||||
const seg = apparentCutSegments[index];
|
||||
if (seg == null) throw new Error();
|
||||
setEditingSegmentTags(getSegmentTags(seg));
|
||||
}, [apparentCutSegments]);
|
||||
|
||||
const editCurrentSegmentTags = useCallback(() => {
|
||||
@ -2219,8 +2226,8 @@ function App() {
|
||||
try {
|
||||
setWorking({ text: i18n.t('Extracting track') });
|
||||
// setStreamsSelectorShown(false);
|
||||
const extractedPaths = await extractStreams({ customOutDir, filePath, streams: mainStreams.filter((s) => s.index === index), enableOverwriteOutput });
|
||||
if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: extractedPaths[0], text: i18n.t('Track has been extracted') });
|
||||
const [firstExtractedPath] = await extractStreams({ customOutDir, filePath, streams: mainStreams.filter((s) => s.index === index), enableOverwriteOutput });
|
||||
if (!hideAllNotifications && firstExtractedPath != null) openDirToast({ icon: 'success', filePath: firstExtractedPath, text: i18n.t('Track has been extracted') });
|
||||
} catch (err) {
|
||||
if (err instanceof RefuseOverwriteError) {
|
||||
showRefuseToOverwrite();
|
||||
|
@ -5,8 +5,10 @@ import { FaAngleRight, FaFile } from 'react-icons/fa';
|
||||
import useContextMenu from '../hooks/useContextMenu';
|
||||
import { primaryTextColor } from '../colors';
|
||||
|
||||
const BatchFile = memo(({ path, isOpen, isSelected, name, onSelect, onDelete }) => {
|
||||
const ref = useRef();
|
||||
const BatchFile = memo(({ path, isOpen, isSelected, name, onSelect, onDelete }: {
|
||||
path: string, isOpen: boolean, isSelected: boolean, name: string, onSelect: (a: string) => void, onDelete: (a: string) => void
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const contextMenuTemplate = useMemo(() => [
|
@ -225,7 +225,7 @@ const ConcatDialog = memo(({ isShown, onHide, paths, onConcat, alwaysConcatMulti
|
||||
|
||||
<Checkbox checked={preserveMetadataOnMerge} onChange={(e) => setPreserveMetadataOnMerge(e.target.checked)} label={t('Preserve original metadata when merging? (slow)')} />
|
||||
|
||||
{isMov(fileFormat) && <Checkbox checked={preserveMovData} onChange={(e) => setPreserveMovData(e.target.checked)} label={t('Preserve all MP4/MOV metadata?')} />}
|
||||
{fileFormat != null && isMov(fileFormat) && <Checkbox checked={preserveMovData} onChange={(e) => setPreserveMovData(e.target.checked)} label={t('Preserve all MP4/MOV metadata?')} />}
|
||||
|
||||
<Checkbox checked={segmentsToChapters} onChange={(e) => setSegmentsToChapters(e.target.checked)} label={t('Create chapters from merged segments? (slow)')} />
|
||||
|
||||
|
@ -1,10 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { primaryTextColor } from '../colors';
|
||||
|
||||
export const highlightedTextStyle = { textDecoration: 'underline', textUnderlineOffset: '.2em', textDecorationColor: primaryTextColor, color: 'var(--gray12)', borderRadius: '.4em' };
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
const HighlightedText = memo(({ children, style, ...props }) => <span {...props} style={{ ...highlightedTextStyle, ...style }}>{children}</span>);
|
||||
|
||||
export default HighlightedText;
|
12
src/components/HighlightedText.tsx
Normal file
12
src/components/HighlightedText.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { CSSProperties, HTMLAttributes, memo } from 'react';
|
||||
|
||||
import { primaryTextColor } from '../colors';
|
||||
|
||||
export const highlightedTextStyle: CSSProperties = { textDecoration: 'underline', textUnderlineOffset: '.2em', textDecorationColor: primaryTextColor, color: 'var(--gray12)', borderRadius: '.4em' };
|
||||
|
||||
function HighlightedText({ children, style, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return <span {...props} style={{ ...highlightedTextStyle, ...style }}>{children}</span>;
|
||||
}
|
||||
|
||||
export default memo(HighlightedText);
|
@ -9,11 +9,12 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
import Swal from '../swal';
|
||||
import HighlightedText from './HighlightedText';
|
||||
import { defaultOutSegTemplate, segNumVariable, segSuffixVariable } from '../util/outputNameTemplate';
|
||||
import { defaultOutSegTemplate, segNumVariable, segSuffixVariable, generateOutSegFileNames as generateOutSegFileNamesRaw } from '../util/outputNameTemplate';
|
||||
import useUserSettings from '../hooks/useUserSettings';
|
||||
import Switch from './Switch';
|
||||
import Select from './Select';
|
||||
import TextInput from './TextInput';
|
||||
import { SegmentToExport } from '../types';
|
||||
|
||||
const ReactSwal = withReactContent(Swal);
|
||||
|
||||
@ -23,16 +24,18 @@ const formatVariable = (variable) => `\${${variable}}`;
|
||||
|
||||
const extVar = formatVariable('EXT');
|
||||
|
||||
const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe }) => {
|
||||
const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe }: {
|
||||
outSegTemplate: string, setOutSegTemplate: (text: string) => void, generateOutSegFileNames: (a: { segments?: SegmentToExport[], template: string }) => ReturnType<typeof generateOutSegFileNamesRaw>, currentSegIndexSafe: number,
|
||||
}) => {
|
||||
const { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding } = useUserSettings();
|
||||
|
||||
const [text, setText] = useState(outSegTemplate);
|
||||
const [debouncedText] = useDebounce(text, 500);
|
||||
const [validText, setValidText] = useState();
|
||||
const [outSegProblems, setOutSegProblems] = useState({ error: undefined, sameAsInputFileNameWarning: false });
|
||||
const [outSegFileNames, setOutSegFileNames] = useState();
|
||||
const [shown, setShown] = useState();
|
||||
const inputRef = useRef();
|
||||
const [validText, setValidText] = useState<string>();
|
||||
const [outSegProblems, setOutSegProblems] = useState<{ error?: string, sameAsInputFileNameWarning?: boolean }>({ error: undefined, sameAsInputFileNameWarning: false });
|
||||
const [outSegFileNames, setOutSegFileNames] = useState<string[]>();
|
||||
const [shown, setShown] = useState<boolean>();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -48,21 +51,25 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
|
||||
setValidText(outSegs.outSegProblems.error == null ? debouncedText : undefined);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setValidText();
|
||||
setOutSegProblems({ error: err.message });
|
||||
setValidText(undefined);
|
||||
setOutSegProblems({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, [debouncedText, generateOutSegFileNames, t]);
|
||||
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
const isMissingExtension = validText != null && !validText.endsWith(extVar);
|
||||
|
||||
const onAllSegmentsPreviewPress = () => ReactSwal.fire({
|
||||
title: t('Resulting segment file names', { count: outSegFileNames.length }),
|
||||
html: (
|
||||
<div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}>
|
||||
{outSegFileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)}
|
||||
</div>
|
||||
) });
|
||||
const onAllSegmentsPreviewPress = useCallback(() => {
|
||||
if (outSegFileNames == null) return;
|
||||
ReactSwal.fire({
|
||||
title: t('Resulting segment file names', { count: outSegFileNames.length }),
|
||||
html: (
|
||||
<div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}>
|
||||
{outSegFileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}, [outSegFileNames, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (validText != null) setOutSegTemplate(validText);
|
||||
@ -83,12 +90,14 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
|
||||
|
||||
const onTextChange = useCallback((e) => setText(e.target.value), []);
|
||||
|
||||
const needToShow = shown || outSegProblems.error != null || outSegProblems.sameAsInputFileNameWarning;
|
||||
const gotImportantMessage = outSegProblems.error != null || outSegProblems.sameAsInputFileNameWarning;
|
||||
const needToShow = shown || gotImportantMessage;
|
||||
|
||||
const onVariableClick = useCallback((variable) => {
|
||||
const input = inputRef.current;
|
||||
const startPos = input.selectionStart;
|
||||
const endPos = input.selectionEnd;
|
||||
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)}`}`;
|
||||
setText(newValue);
|
||||
@ -114,7 +123,7 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
|
||||
{outSegFileNames != null && <Button height={20} onClick={onAllSegmentsPreviewPress} marginLeft={5}>{t('Preview')}</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" appearance="primary" />
|
||||
{!gotImportantMessage && <IconButton title={t('Close')} icon={TickIcon} height={20} onClick={onHideClick} marginLeft={5} intent="success" appearance="primary" />}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center', marginBottom: '.7em' }}>
|
||||
@ -149,7 +158,7 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
|
||||
{hasTextNumericPaddedValue && (
|
||||
<div style={{ marginBottom: '.3em' }}>
|
||||
<Select value={outputFileNameMinZeroPadding} onChange={(e) => setOutputFileNameMinZeroPadding(parseInt(e.target.value, 10))} style={{ marginRight: '1em', fontSize: '1em' }}>
|
||||
{Array.from({ length: 10 }).map((v, i) => i + 1).map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
{Array.from({ length: 10 }).map((_v, i) => i + 1).map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</Select>
|
||||
Minimum numeric padded length
|
||||
</div>
|
@ -1,10 +0,0 @@
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
const inputStyle = { borderRadius: '.4em', flexGrow: 1, fontFamily: 'inherit', fontSize: '.8em', backgroundColor: 'var(--gray3)', color: 'var(--gray12)', border: '1px solid var(--gray7)', appearance: 'none' };
|
||||
|
||||
const TextInput = forwardRef(({ style, ...props }, forwardedRef) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<input type="text" ref={forwardedRef} style={{ ...inputStyle, ...style }} {...props} />
|
||||
));
|
||||
|
||||
export default TextInput;
|
10
src/components/TextInput.tsx
Normal file
10
src/components/TextInput.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { CSSProperties, forwardRef } from 'react';
|
||||
|
||||
const inputStyle: CSSProperties = { borderRadius: '.4em', flexGrow: 1, fontFamily: 'inherit', fontSize: '.8em', backgroundColor: 'var(--gray3)', color: 'var(--gray12)', border: '1px solid var(--gray7)', appearance: 'none' };
|
||||
|
||||
const TextInput = forwardRef<HTMLInputElement, JSX.IntrinsicElements['input']>(({ style, ...props }, forwardedRef) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<input type="text" ref={forwardedRef} style={{ ...inputStyle, ...style }} {...props} />
|
||||
));
|
||||
|
||||
export default TextInput;
|
@ -115,8 +115,9 @@ export async function askForEdlImport({ type, fps }: { type: EdlImportType, fps?
|
||||
else if (type === 'llc') filters = [{ name: i18n.t('LosslessCut project'), extensions: ['llc'] }];
|
||||
|
||||
const { canceled, filePaths } = await showOpenDialog({ properties: ['openFile'], filters });
|
||||
if (canceled || filePaths.length === 0) return [];
|
||||
return readEdlFile({ type, path: filePaths[0], fps });
|
||||
const [firstFilePath] = filePaths;
|
||||
if (canceled || firstFilePath == null) return [];
|
||||
return readEdlFile({ type, path: firstFilePath, fps });
|
||||
}
|
||||
|
||||
export async function exportEdlFile({ type, cutSegments, customOutDir, filePath, getFrameCount }: {
|
||||
|
@ -432,6 +432,7 @@ async function extractAttachmentStreams({ customOutDir, filePath, streams, enabl
|
||||
const outPaths = await pMap(streams, async ({ index, codec_name: codec, codec_type: type }) => {
|
||||
const ext = codec || 'bin';
|
||||
const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` });
|
||||
if (outPath == null) throw new Error();
|
||||
if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError();
|
||||
|
||||
streamArgs = [
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import type { MenuItem, MenuItemConstructorOptions } from 'electron';
|
||||
|
||||
import useNativeMenu from './useNativeMenu';
|
||||
|
||||
// https://github.com/transflow/use-electron-context-menu
|
||||
export default function useContextMenu(
|
||||
ref,
|
||||
template,
|
||||
options = {},
|
||||
ref: RefObject<HTMLElement>,
|
||||
template: (MenuItemConstructorOptions | MenuItem)[],
|
||||
) {
|
||||
const { openMenu, closeMenu } = useNativeMenu(template, options);
|
||||
const { openMenu, closeMenu } = useNativeMenu(template);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
@ -13,7 +13,7 @@ const { join, resolve, dirname } = window.require('path');
|
||||
const { pathExists } = window.require('fs-extra');
|
||||
const { writeFile, mkdir } = window.require('fs/promises');
|
||||
|
||||
async function writeChaptersFfmetadata(outDir, chapters) {
|
||||
async function writeChaptersFfmetadata(outDir: string, chapters: { start: number, end: number, name?: string }[]) {
|
||||
if (!chapters || chapters.length === 0) return undefined;
|
||||
|
||||
const path = join(outDir, `ffmetadata-${Date.now()}.txt`);
|
||||
@ -26,8 +26,8 @@ async function writeChaptersFfmetadata(outDir, chapters) {
|
||||
return path;
|
||||
}
|
||||
|
||||
function getMovFlags({ preserveMovData, movFastStart }) {
|
||||
const flags = [];
|
||||
function getMovFlags({ preserveMovData, movFastStart }: { preserveMovData: boolean, movFastStart: boolean }) {
|
||||
const flags: string[] = [];
|
||||
|
||||
// https://video.stackexchange.com/a/26084/29486
|
||||
// https://github.com/mifi/lossless-cut/issues/331#issuecomment-623401794
|
||||
@ -52,7 +52,7 @@ function getMatroskaFlags() {
|
||||
|
||||
const getChaptersInputArgs = (ffmetadataPath) => (ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []);
|
||||
|
||||
async function tryDeleteFiles(paths) {
|
||||
async function tryDeleteFiles(paths: string[]) {
|
||||
return pMap(paths, (path) => unlinkWithRetry(path).catch((err) => console.error('Failed to delete', path, err)), { concurrency: 5 });
|
||||
}
|
||||
|
||||
@ -80,12 +80,12 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
||||
}
|
||||
|
||||
try {
|
||||
let inputArgs = [];
|
||||
let inputArgs: string[] = [];
|
||||
let inputIndex = 0;
|
||||
|
||||
// Keep track of input index to be used later
|
||||
// eslint-disable-next-line no-inner-declarations
|
||||
function addInput(args) {
|
||||
function addInput(args: string[]) {
|
||||
inputArgs = [...inputArgs, ...args];
|
||||
const retIndex = inputIndex;
|
||||
inputIndex += 1;
|
||||
@ -245,10 +245,8 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
||||
const mapStreamsArgs = getMapStreamsArgs({ copyFileStreams: copyFileStreamsFiltered, allFilesMeta, outFormat });
|
||||
|
||||
const customParamsArgs = (() => {
|
||||
const ret = [];
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const ret: string[] = [];
|
||||
for (const [fileId, fileParams] of paramsByStreamId.entries()) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const [streamId, streamParams] of fileParams.entries()) {
|
||||
const outputIndex = mapInputStreamIndexToOutputIndex(fileId, parseInt(streamId, 10));
|
||||
if (outputIndex != null) {
|
||||
@ -269,7 +267,6 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
||||
// custom stream metadata tags
|
||||
const customTags = streamParams.get('customTags');
|
||||
if (customTags != null) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const [tag, value] of Object.entries(customTags)) {
|
||||
ret.push(`-metadata:s:${outputIndex}`, `${tag}=${value}`);
|
||||
}
|
||||
@ -285,7 +282,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
||||
// No progress if we set loglevel warning :(
|
||||
// '-loglevel', 'warning',
|
||||
|
||||
...getOutputPlaybackRateArgs(outputPlaybackRate),
|
||||
...getOutputPlaybackRateArgs(),
|
||||
|
||||
...inputArgs,
|
||||
|
||||
@ -325,7 +322,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
||||
logStdoutStderr(result);
|
||||
|
||||
await transferTimestamps({ inPath: filePath, outPath, cutFrom, cutTo, treatInputFileModifiedTimeAsStart, duration: isDurationValid(videoDuration) ? videoDuration : undefined, treatOutputFileModifiedTimeAsStart });
|
||||
}, [cutFromAdjustmentFrames, filePath, getOutputPlaybackRateArgs, outputPlaybackRate, shouldSkipExistingFile, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]);
|
||||
}, [cutFromAdjustmentFrames, filePath, getOutputPlaybackRateArgs, shouldSkipExistingFile, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]);
|
||||
|
||||
const cutMultiple = useCallback(async ({
|
||||
outputDir, customOutDir, segments, outSegFileNames, videoDuration, rotation, detectedFps,
|
||||
@ -388,6 +385,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
||||
// eslint-disable-next-line no-shadow
|
||||
async function cutEncodeSmartPartWrapper({ cutFrom, cutTo, outPath }) {
|
||||
if (await shouldSkipExistingFile(outPath)) return;
|
||||
if (videoCodec == null || videoBitrate == null || videoTimebase == null) throw new Error();
|
||||
await cutEncodeSmartPart({ filePath, cutFrom, cutTo, outPath, outFormat, videoCodec, videoBitrate, videoStreamIndex, videoTimebase, allFilesMeta, copyFileStreams: copyFileStreamsFiltered, ffmpegExperimental });
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import dataUriToBuffer from 'data-uri-to-buffer';
|
||||
import pMap from 'p-map';
|
||||
import { useCallback } from 'react';
|
||||
import type * as FsPromises from 'fs/promises';
|
||||
|
||||
import { getSuffixedOutPath, getOutDir, transferTimestamps, getSuffixedFileName, getOutPath, escapeRegExp, fsOperationWithRetry } from '../util';
|
||||
import { getNumDigits } from '../segments';
|
||||
@ -8,7 +9,7 @@ import { getNumDigits } from '../segments';
|
||||
import { captureFrame as ffmpegCaptureFrame, captureFrames as ffmpegCaptureFrames } from '../ffmpeg';
|
||||
|
||||
const mime = window.require('mime-types');
|
||||
const { rename, readdir, writeFile } = window.require('fs/promises');
|
||||
const { rename, readdir, writeFile }: typeof FsPromises = window.require('fs/promises');
|
||||
|
||||
|
||||
function getFrameFromVideo(video, format, quality) {
|
||||
@ -16,7 +17,7 @@ function getFrameFromVideo(video, format, quality) {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||
canvas.getContext('2d')!.drawImage(video, 0, 0);
|
||||
|
||||
const dataUri = canvas.toDataURL(`image/${format}`, quality);
|
||||
|
||||
@ -24,8 +25,10 @@ function getFrameFromVideo(video, format, quality) {
|
||||
}
|
||||
|
||||
export default ({ formatTimecode, treatOutputFileModifiedTimeAsStart }) => {
|
||||
const captureFramesRange = useCallback(async ({ customOutDir, filePath, fps, fromTime, toTime, estimatedMaxNumFiles, captureFormat, quality, filter, onProgress, outputTimestamps }) => {
|
||||
const getSuffix = (prefix) => `${prefix}.${captureFormat}`;
|
||||
const captureFramesRange = useCallback(async ({ customOutDir, filePath, fps, fromTime, toTime, estimatedMaxNumFiles, captureFormat, quality, filter, onProgress, outputTimestamps }: {
|
||||
customOutDir, filePath: string, fps: number, fromTime: number, toTime: number, estimatedMaxNumFiles: number, captureFormat: string, quality: number, filter?: string, onProgress: (a: number) => void, outputTimestamps: boolean
|
||||
}) => {
|
||||
const getSuffix = (prefix: string) => `${prefix}.${captureFormat}`;
|
||||
|
||||
if (!outputTimestamps) {
|
||||
const numDigits = getNumDigits(estimatedMaxNumFiles);
|
||||
@ -48,21 +51,21 @@ export default ({ formatTimecode, treatOutputFileModifiedTimeAsStart }) => {
|
||||
const files = await readdir(outDir);
|
||||
|
||||
// https://github.com/mifi/lossless-cut/issues/1139
|
||||
const matches = files.map((fileName) => {
|
||||
const matches = files.flatMap((fileName) => {
|
||||
const escapedRegexp = escapeRegExp(getSuffixedFileName(filePath, tmpSuffix));
|
||||
const regexp = `^${escapedRegexp}(\\d+)`;
|
||||
const match = fileName.match(new RegExp(regexp));
|
||||
if (!match) return undefined;
|
||||
const frameNum = parseInt(match[1], 10);
|
||||
if (Number.isNaN(frameNum) || frameNum < 0) return undefined;
|
||||
return { fileName, frameNum };
|
||||
}).filter((it) => it != null);
|
||||
if (!match) return [];
|
||||
const frameNum = parseInt(match[1]!, 10);
|
||||
if (Number.isNaN(frameNum) || frameNum < 0) return [];
|
||||
return [{ fileName, frameNum }];
|
||||
});
|
||||
|
||||
console.log('Renaming temp files...');
|
||||
const outPaths = await pMap(matches, async ({ fileName, frameNum }) => {
|
||||
const duration = formatTimecode({ seconds: fromTime + (frameNum / fps), fileNameFriendly: true });
|
||||
const renameFromPath = getOutPath({ customOutDir, filePath, fileName });
|
||||
const renameToPath = getOutPath({ customOutDir, filePath, fileName: getSuffixedFileName(filePath, getSuffix(duration, captureFormat)) });
|
||||
const renameToPath = getOutPath({ customOutDir, filePath, fileName: getSuffixedFileName(filePath, getSuffix(duration)) });
|
||||
await fsOperationWithRetry(async () => rename(renameFromPath, renameToPath));
|
||||
return renameToPath;
|
||||
}, { concurrency: 1 });
|
||||
@ -70,7 +73,9 @@ export default ({ formatTimecode, treatOutputFileModifiedTimeAsStart }) => {
|
||||
return outPaths[0];
|
||||
}, [formatTimecode]);
|
||||
|
||||
const captureFrameFromFfmpeg = useCallback(async ({ customOutDir, filePath, fromTime, captureFormat, quality }) => {
|
||||
const captureFrameFromFfmpeg = useCallback(async ({ customOutDir, filePath, fromTime, captureFormat, quality }: {
|
||||
customOutDir?: string, filePath?: string, fromTime: number, captureFormat: string, quality: number,
|
||||
}) => {
|
||||
const time = formatTimecode({ seconds: fromTime, fileNameFriendly: true });
|
||||
const nameSuffix = `${time}.${captureFormat}`;
|
||||
const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix });
|
||||
@ -80,7 +85,9 @@ export default ({ formatTimecode, treatOutputFileModifiedTimeAsStart }) => {
|
||||
return outPath;
|
||||
}, [formatTimecode, treatOutputFileModifiedTimeAsStart]);
|
||||
|
||||
const captureFrameFromTag = useCallback(async ({ customOutDir, filePath, currentTime, captureFormat, video, quality }) => {
|
||||
const captureFrameFromTag = useCallback(async ({ customOutDir, filePath, currentTime, captureFormat, video, quality }: {
|
||||
customOutDir?: string, filePath?: string, currentTime: number, captureFormat: string, video: HTMLVideoElement, quality: number,
|
||||
}) => {
|
||||
const buf = getFrameFromVideo(video, captureFormat, quality);
|
||||
|
||||
const ext = mime.extension(buf.type);
|
@ -1,20 +1,22 @@
|
||||
import type { Menu as MenuType, MenuItemConstructorOptions, MenuItem } from 'electron';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
// TODO pull out?
|
||||
const remote = window.require('@electron/remote');
|
||||
const { Menu } = remote;
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
const Menu: typeof MenuType = remote.Menu;
|
||||
|
||||
// https://github.com/transflow/use-electron-context-menu
|
||||
// https://www.electronjs.org/docs/latest/api/menu-item
|
||||
export default function useNativeMenu(
|
||||
template,
|
||||
options = {},
|
||||
template: (MenuItemConstructorOptions | MenuItem)[],
|
||||
options: { x?: number, y?: number, onContext?: (e: MouseEvent) => void, onClose?: () => void } = {},
|
||||
) {
|
||||
const menu = useMemo(() => Menu.buildFromTemplate(template), [template]);
|
||||
|
||||
const { x, y, onContext, onClose } = options;
|
||||
|
||||
const openMenu = useCallback((e) => {
|
||||
const openMenu = useCallback((e: MouseEvent) => {
|
||||
menu.popup({
|
||||
window: remote.getCurrentWindow(),
|
||||
x,
|
@ -21,7 +21,7 @@ const { blackDetect, silenceDetect } = remote.require('./ffmpeg');
|
||||
|
||||
|
||||
export default ({ filePath, workingRef, setWorking, setCutProgress, videoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly }: {
|
||||
filePath: string, workingRef: MutableRefObject<boolean>, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, setCutProgress: (a: number | undefined) => void, videoStream, duration?: number, getRelevantTime: () => number, maxLabelLength: number, checkFileOpened: () => boolean, invertCutSegments: boolean, segmentsToChaptersOnly: boolean,
|
||||
filePath?: string, workingRef: MutableRefObject<boolean>, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, setCutProgress: (a: number | undefined) => void, videoStream, duration?: number, getRelevantTime: () => number, maxLabelLength: number, checkFileOpened: () => boolean, invertCutSegments: boolean, segmentsToChaptersOnly: boolean,
|
||||
}) => {
|
||||
// Segment related state
|
||||
const segCounterRef = useRef(0);
|
||||
@ -266,6 +266,7 @@ export default ({ filePath, workingRef, setWorking, setCutProgress, videoStream,
|
||||
|
||||
async function align(key) {
|
||||
const time = newSegment[key];
|
||||
if (filePath == null) throw new Error();
|
||||
const keyframe = await findKeyframeNearTime({ filePath, streamIndex: videoStream.index, time, mode });
|
||||
if (keyframe == null) throw new Error(`Cannot find any keyframe within 60 seconds of frame ${time}`);
|
||||
newSegment[key] = keyframe;
|
||||
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { MotionConfig } from 'framer-motion';
|
||||
import { enableMapSet } from 'immer';
|
||||
import * as Electron from 'electron';
|
||||
import Remote from '@electron/remote';
|
||||
|
||||
import 'sweetalert2/dist/sweetalert2.css';
|
||||
|
||||
@ -28,7 +29,10 @@ import './main.css';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
require: (module: 'electron') => typeof Electron;
|
||||
require: <T extends string>(module: T) => T extends '@electron/remote' ? typeof Remote :
|
||||
T extends 'electron' ? typeof Electron :
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
any;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { nanoid } from 'nanoid';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import minBy from 'lodash/minBy';
|
||||
import maxBy from 'lodash/maxBy';
|
||||
import { ApparentSegmentBase, InverseSegment, PlaybackMode, SegmentBase } from './types';
|
||||
import { ApparentCutSegment, ApparentSegmentBase, InverseSegment, PlaybackMode, SegmentBase, SegmentTags } from './types';
|
||||
|
||||
|
||||
export const isDurationValid = (duration?: number): duration is number => duration != null && Number.isFinite(duration) && duration > 0;
|
||||
@ -50,7 +50,7 @@ export function findSegmentsAtCursor(apparentSegments, currentTime) {
|
||||
return indexes;
|
||||
}
|
||||
|
||||
export const getSegmentTags = (segment) => (segment.tags || {});
|
||||
export const getSegmentTags = (segment: { tags?: SegmentTags | undefined }) => (segment.tags || {});
|
||||
|
||||
export const sortSegments = <T>(segments: T[]) => sortBy(segments, 'start');
|
||||
|
||||
@ -129,7 +129,7 @@ export function hasAnySegmentOverlap(sortedSegments) {
|
||||
return overlappingGroups.length > 0;
|
||||
}
|
||||
|
||||
export function invertSegments(sortedCutSegments, includeFirstSegment: boolean, includeLastSegment: boolean, duration?: number) {
|
||||
export function invertSegments(sortedCutSegments: ApparentCutSegment[], includeFirstSegment: boolean, includeLastSegment: boolean, duration?: number) {
|
||||
if (sortedCutSegments.length === 0) return undefined;
|
||||
|
||||
if (hasAnySegmentOverlap(sortedCutSegments)) return undefined;
|
||||
@ -137,7 +137,7 @@ export function invertSegments(sortedCutSegments, includeFirstSegment: boolean,
|
||||
const ret: InverseSegment[] = [];
|
||||
|
||||
if (includeFirstSegment) {
|
||||
const firstSeg = sortedCutSegments[0];
|
||||
const firstSeg = sortedCutSegments[0]!;
|
||||
if (firstSeg.start > 0) {
|
||||
ret.push({
|
||||
start: 0,
|
||||
@ -149,7 +149,7 @@ export function invertSegments(sortedCutSegments, includeFirstSegment: boolean,
|
||||
|
||||
sortedCutSegments.forEach((cutSegment, i) => {
|
||||
if (i === 0) return;
|
||||
const previousSeg = sortedCutSegments[i - 1];
|
||||
const previousSeg = sortedCutSegments[i - 1]!;
|
||||
const inverted: InverseSegment = {
|
||||
start: previousSeg.end,
|
||||
end: cutSegment.start,
|
||||
|
17
src/types.ts
17
src/types.ts
@ -9,21 +9,34 @@ export interface ApparentSegmentBase {
|
||||
}
|
||||
|
||||
|
||||
export type SegmentTags = Record<string, string>;
|
||||
|
||||
export interface StateSegment extends SegmentBase {
|
||||
name: string;
|
||||
segId: string;
|
||||
segColorIndex?: number | undefined;
|
||||
tags?: Record<string, string> | undefined;
|
||||
tags?: SegmentTags | undefined;
|
||||
}
|
||||
|
||||
export interface Segment extends SegmentBase {
|
||||
name?: string,
|
||||
}
|
||||
|
||||
export interface InverseSegment extends SegmentBase {
|
||||
export interface ApparentCutSegment extends ApparentSegmentBase {
|
||||
segId?: string | undefined,
|
||||
tags?: SegmentTags | undefined;
|
||||
}
|
||||
|
||||
export interface InverseSegment extends ApparentSegmentBase {
|
||||
segId?: string,
|
||||
}
|
||||
|
||||
export interface SegmentToExport extends ApparentSegmentBase {
|
||||
name?: string | undefined;
|
||||
segId?: string | undefined;
|
||||
tags?: SegmentTags | undefined;
|
||||
}
|
||||
|
||||
export type PlaybackMode = 'loop-segment-start-end' | 'loop-segment' | 'play-segment-once' | 'loop-selected-segments';
|
||||
|
||||
export type Html5ifyMode = 'fastest' | 'fast-audio-remux' | 'fast-audio' | 'fast' | 'slow' | 'slow-audio' | 'slowest';
|
||||
|
50
src/util.ts
50
src/util.ts
@ -3,17 +3,21 @@ import pMap from 'p-map';
|
||||
import ky from 'ky';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import pRetry from 'p-retry';
|
||||
import pRetry, { Options } from 'p-retry';
|
||||
import { ExecaError } from 'execa';
|
||||
import type * as FsPromises from 'fs/promises';
|
||||
import type * as Os from 'os';
|
||||
import type * as FsExtra from 'fs-extra';
|
||||
import type { PlatformPath } from 'path';
|
||||
|
||||
import isDev from './isDev';
|
||||
import Swal, { toast } from './swal';
|
||||
import { ffmpegExtractWindow } from './util/constants';
|
||||
|
||||
const { dirname, parse: parsePath, join, extname, isAbsolute, resolve, basename } = window.require('path');
|
||||
const fsExtra = window.require('fs-extra');
|
||||
const { stat, lstat, readdir, utimes, unlink } = window.require('fs/promises');
|
||||
const os = window.require('os');
|
||||
const { dirname, parse: parsePath, join, extname, isAbsolute, resolve, basename }: PlatformPath = window.require('path');
|
||||
const fsExtra: typeof FsExtra = window.require('fs-extra');
|
||||
const { stat, lstat, readdir, utimes, unlink }: typeof FsPromises = window.require('fs/promises');
|
||||
const os: typeof Os = window.require('os');
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
const remote = window.require('@electron/remote');
|
||||
|
||||
@ -27,9 +31,10 @@ export function getFileDir(filePath?: string) {
|
||||
return filePath ? dirname(filePath) : undefined;
|
||||
}
|
||||
|
||||
export function getOutDir(customOutDir?: string, filePath?: string) {
|
||||
if (customOutDir) return customOutDir;
|
||||
if (filePath) return getFileDir(filePath);
|
||||
export function getOutDir<T1 extends string | undefined, T2 extends string | undefined>(customOutDir?: T1, filePath?: T2): T1 extends string ? string : T2 extends string ? string : undefined;
|
||||
export function getOutDir(customOutDir?: string | undefined, filePath?: string | undefined) {
|
||||
if (customOutDir != null) return customOutDir;
|
||||
if (filePath != null) return getFileDir(filePath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -39,15 +44,17 @@ function getFileBaseName(filePath?: string) {
|
||||
return parsed.name;
|
||||
}
|
||||
|
||||
export function getOutPath({ customOutDir, filePath, fileName }: { customOutDir?: string, filePath?: string, fileName?: string }) {
|
||||
if (!filePath) return undefined;
|
||||
export function getOutPath<T extends string | undefined>(a: { customOutDir?: string, filePath?: T, fileName: string }): T extends string ? string : undefined;
|
||||
export function getOutPath({ customOutDir, filePath, fileName }: { customOutDir?: string, filePath?: string | undefined, fileName: string }) {
|
||||
if (filePath == null) return undefined;
|
||||
return join(getOutDir(customOutDir, filePath), fileName);
|
||||
}
|
||||
|
||||
export const getSuffixedFileName = (filePath: string | undefined, nameSuffix: string) => `${getFileBaseName(filePath)}-${nameSuffix}`;
|
||||
|
||||
export function getSuffixedOutPath({ customOutDir, filePath, nameSuffix }: { customOutDir?: string, filePath?: string, nameSuffix: string }) {
|
||||
if (!filePath) return undefined;
|
||||
export function getSuffixedOutPath<T extends string | undefined>(a: { customOutDir?: string, filePath?: T, nameSuffix: string }): T extends string ? string : undefined;
|
||||
export function getSuffixedOutPath({ customOutDir, filePath, nameSuffix }: { customOutDir?: string, filePath?: string | undefined, nameSuffix: string }) {
|
||||
if (filePath == null) return undefined;
|
||||
return getOutPath({ customOutDir, filePath, fileName: getSuffixedFileName(filePath, nameSuffix) });
|
||||
}
|
||||
|
||||
@ -100,7 +107,7 @@ export async function dirExists(dirPath) {
|
||||
const testFailFsOperation = false;
|
||||
|
||||
// Retry because sometimes write operations fail on windows due to the file being locked for various reasons (often anti-virus) #272 #1797 #1704
|
||||
export async function fsOperationWithRetry(operation, { signal, retries = 10, minTimeout = 100, maxTimeout = 2000, ...opts }) {
|
||||
export async function fsOperationWithRetry(operation, { signal, retries = 10, minTimeout = 100, maxTimeout = 2000, ...opts }: Options & { retries?: number, minTimeout?: number, maxTimeout?: number } = {}) {
|
||||
return pRetry(async () => {
|
||||
if (testFailFsOperation && Math.random() > 0.3) throw Object.assign(new Error('test delete failure'), { code: 'EPERM' });
|
||||
await operation();
|
||||
@ -116,10 +123,9 @@ export async function fsOperationWithRetry(operation, { signal, retries = 10, mi
|
||||
}
|
||||
|
||||
// example error: index-18074aaf.js:166 Failed to delete C:\Users\USERNAME\Desktop\RC\New folder\2023-12-27 21-45-22 (GMT p5)-merged-1703933052361-00.01.04.915-00.01.07.424-seg1.mp4 Error: EPERM: operation not permitted, unlink 'C:\Users\USERNAME\Desktop\RC\New folder\2023-12-27 21-45-22 (GMT p5)-merged-1703933052361-00.01.04.915-00.01.07.424-seg1.mp4'
|
||||
export const unlinkWithRetry = async (path, options) => fsOperationWithRetry(async () => unlink(path), { ...options, onFailedAttempt: (error) => console.warn('Retrying delete', path, error.attemptNumber) });
|
||||
export const unlinkWithRetry = async (path: string, options?: Options) => fsOperationWithRetry(async () => unlink(path), { ...options, onFailedAttempt: (error) => console.warn('Retrying delete', path, error.attemptNumber) });
|
||||
// example error: index-18074aaf.js:160 Error: EPERM: operation not permitted, utime 'C:\Users\USERNAME\Desktop\RC\New folder\2023-12-27 21-45-22 (GMT p5)-merged-1703933052361-cut-merged-1703933070237.mp4'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const utimesWithRetry = async (path: string, atime: number, mtime: number, options?: any) => fsOperationWithRetry(async () => utimes(path, atime, mtime), { ...options, onFailedAttempt: (error) => console.warn('Retrying utimes', path, error.attemptNumber) });
|
||||
export const utimesWithRetry = async (path: string, atime: number, mtime: number, options?: Options) => fsOperationWithRetry(async () => utimes(path, atime, mtime), { ...options, onFailedAttempt: (error) => console.warn('Retrying utimes', path, error.attemptNumber) });
|
||||
|
||||
export const getFrameDuration = (fps?: number) => 1 / (fps ?? 30);
|
||||
|
||||
@ -192,7 +198,7 @@ export const arch = os.arch();
|
||||
export const isWindows = platform === 'win32';
|
||||
export const isMac = platform === 'darwin';
|
||||
|
||||
export function getExtensionForFormat(format) {
|
||||
export function getExtensionForFormat(format: string) {
|
||||
const ext = {
|
||||
matroska: 'mkv',
|
||||
ipod: 'm4a',
|
||||
@ -203,7 +209,9 @@ export function getExtensionForFormat(format) {
|
||||
return ext || format;
|
||||
}
|
||||
|
||||
export function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }) {
|
||||
export function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }: {
|
||||
isCustomFormatSelected?: boolean, outFormat: string, filePath: string,
|
||||
}) {
|
||||
if (!isCustomFormatSelected) {
|
||||
const ext = extname(filePath);
|
||||
// QuickTime is quirky about the file extension of mov files (has to be .mov)
|
||||
@ -232,11 +240,12 @@ export async function findExistingHtml5FriendlyFile(fp, cod) {
|
||||
const prefix = getSuffixedFileName(fp, html5ifiedPrefix);
|
||||
|
||||
const outDir = getOutDir(cod, fp);
|
||||
if (outDir == null) throw new Error();
|
||||
const dirEntries = await readdir(outDir);
|
||||
|
||||
const html5ifiedDirEntries = dirEntries.filter((entry) => entry.startsWith(prefix));
|
||||
|
||||
let matches: { entry: string, suffix: string }[] = [];
|
||||
let matches: { entry: string, suffix?: string }[] = [];
|
||||
suffixes.forEach((suffix) => {
|
||||
const entryWithSuffix = html5ifiedDirEntries.find((entry) => new RegExp(`${suffix}\\..*$`).test(entry.replace(prefix, '')));
|
||||
if (entryWithSuffix) matches = [...matches, { entry: entryWithSuffix, suffix }];
|
||||
@ -325,6 +334,7 @@ export async function checkAppPath() {
|
||||
return;
|
||||
}
|
||||
const pathSeg = pathMatch[1];
|
||||
if (pathSeg == null) return;
|
||||
if (pathSeg.startsWith(`57275${mf}.${llc}_`)) return;
|
||||
// this will report the path and may return a msg
|
||||
const url = `https://losslesscut-analytics.mifi.no/${pathSeg.length}/${encodeURIComponent(btoa(pathSeg))}`;
|
||||
@ -381,7 +391,7 @@ function setDocumentExtraTitle(extra) {
|
||||
document.title = extra != null ? `${baseTitle} - ${extra}` : baseTitle;
|
||||
}
|
||||
|
||||
export function setDocumentTitle({ filePath, working, cutProgress }: { filePath: string, working?: string, cutProgress?: number }) {
|
||||
export function setDocumentTitle({ filePath, working, cutProgress }: { filePath?: string, working?: string, cutProgress?: number }) {
|
||||
const parts: string[] = [];
|
||||
if (filePath) parts.push(basename(filePath));
|
||||
if (working) {
|
||||
|
@ -1,22 +1,25 @@
|
||||
import i18n from 'i18next';
|
||||
import lodashTemplate from 'lodash/template';
|
||||
import { PlatformPath } from 'path';
|
||||
|
||||
import { isMac, isWindows, hasDuplicates, filenamify, getOutFileExtension } from '../util';
|
||||
import isDev from '../isDev';
|
||||
import { getSegmentTags, formatSegNum } from '../segments';
|
||||
import { Segment } from '../types';
|
||||
import { SegmentToExport } from '../types';
|
||||
|
||||
|
||||
export const segNumVariable = 'SEG_NUM';
|
||||
export const segSuffixVariable = 'SEG_SUFFIX';
|
||||
|
||||
const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename } = window.require('path');
|
||||
const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename }: PlatformPath = window.require('path');
|
||||
|
||||
function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName }) {
|
||||
let error;
|
||||
|
||||
function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName }: {
|
||||
fileNames: string[], filePath: string, outputDir: string, safeOutputFileName: boolean
|
||||
}) {
|
||||
let error: string | undefined;
|
||||
let sameAsInputFileNameWarning = false;
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const fileName of fileNames) {
|
||||
if (!filePath) {
|
||||
error = i18n.t('No file is loaded');
|
||||
@ -115,7 +118,7 @@ function interpolateSegmentFileName({ template, epochMs, inputFileNameWithoutExt
|
||||
}
|
||||
|
||||
export function generateOutSegFileNames({ segments, template: desiredTemplate, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }: {
|
||||
segments: Segment[], template: string, formatTimecode: (a: { seconds?: number, shorten?: boolean, fileNameFriendly?: boolean }) => string, isCustomFormatSelected: boolean, fileFormat?: string, filePath: string, outputDir: string, safeOutputFileName: string, maxLabelLength: number, outputFileNameMinZeroPadding: number,
|
||||
segments: SegmentToExport[], template: string, formatTimecode: (a: { seconds?: number, shorten?: boolean, fileNameFriendly?: boolean }) => string, isCustomFormatSelected: boolean, fileFormat: string, filePath: string, outputDir: string, safeOutputFileName: boolean, maxLabelLength: number, outputFileNameMinZeroPadding: number,
|
||||
}) {
|
||||
function generate({ template, forceSafeOutputFileName }) {
|
||||
const epochMs = Date.now();
|
||||
@ -126,7 +129,7 @@ export function generateOutSegFileNames({ segments, template: desiredTemplate, f
|
||||
|
||||
// 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).slice(0, Math.max(0, maxLabelLength));
|
||||
const filenamifyOrNot = (fileName: string) => (safeOutputFileName || forceSafeOutputFileName ? filenamify(fileName) : fileName).slice(0, Math.max(0, maxLabelLength));
|
||||
|
||||
function getSegSuffix() {
|
||||
if (name) return `-${filenamifyOrNot(name)}`;
|
||||
|
@ -106,7 +106,7 @@ export function getActiveDisposition(disposition) {
|
||||
return existingActiveDispositionEntry[0]; // return the key
|
||||
}
|
||||
|
||||
export const isMov = (format) => ['ismv', 'ipod', 'mp4', 'mov'].includes(format);
|
||||
export const isMov = (format: string) => ['ismv', 'ipod', 'mp4', 'mov'].includes(format);
|
||||
|
||||
type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | undefined;
|
||||
|
||||
|
17
yarn.lock
17
yarn.lock
@ -1809,6 +1809,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:18":
|
||||
version: 18.19.21
|
||||
resolution: "@types/node@npm:18.19.21"
|
||||
dependencies:
|
||||
undici-types: "npm:~5.26.4"
|
||||
checksum: 3a5c5841f294bc35b5b416a32764b5c0c2f22f4cef48cb7d2e3b4e068a52d5857e50da8e6e0685e743127c70344301c833849a3904ce3bd3f67448da5e85487a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^18.11.18":
|
||||
version: 18.17.6
|
||||
resolution: "@types/node@npm:18.17.6"
|
||||
@ -7699,6 +7708,7 @@ __metadata:
|
||||
"@tsconfig/vite-react": "npm:^3.0.0"
|
||||
"@types/eslint": "npm:^8"
|
||||
"@types/lodash": "npm:^4.14.202"
|
||||
"@types/node": "npm:18"
|
||||
"@types/sortablejs": "npm:^1.15.0"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^6.12.0"
|
||||
"@typescript-eslint/parser": "npm:^6.12.0"
|
||||
@ -11748,6 +11758,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~5.26.4":
|
||||
version: 5.26.5
|
||||
resolution: "undici-types@npm:5.26.5"
|
||||
checksum: 0097779d94bc0fd26f0418b3a05472410408877279141ded2bd449167be1aed7ea5b76f756562cb3586a07f251b90799bab22d9019ceba49c037c76445f7cddd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unique-filename@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "unique-filename@npm:2.0.1"
|
||||
|
Loading…
Reference in New Issue
Block a user