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",
|
"@tsconfig/vite-react": "^3.0.0",
|
||||||
"@types/eslint": "^8",
|
"@types/eslint": "^8",
|
||||||
"@types/lodash": "^4.14.202",
|
"@types/lodash": "^4.14.202",
|
||||||
|
"@types/node": "18",
|
||||||
"@types/sortablejs": "^1.15.0",
|
"@types/sortablejs": "^1.15.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||||
"@typescript-eslint/parser": "^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 BigWaveform from './components/BigWaveform';
|
||||||
|
|
||||||
import isDev from './isDev';
|
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 electron = window.require('electron');
|
||||||
const { exists } = window.require('fs-extra');
|
const { exists } = window.require('fs-extra');
|
||||||
@ -121,7 +121,7 @@ function App() {
|
|||||||
const [rotation, setRotation] = useState(360);
|
const [rotation, setRotation] = useState(360);
|
||||||
const [cutProgress, setCutProgress] = useState<number>();
|
const [cutProgress, setCutProgress] = useState<number>();
|
||||||
const [startTimeOffset, setStartTimeOffset] = useState(0);
|
const [startTimeOffset, setStartTimeOffset] = useState(0);
|
||||||
const [filePath, setFilePath] = useState('');
|
const [filePath, setFilePath] = useState<string>();
|
||||||
const [externalFilesMeta, setExternalFilesMeta] = useState({});
|
const [externalFilesMeta, setExternalFilesMeta] = useState({});
|
||||||
const [customTagsByFile, setCustomTagsByFile] = useState({});
|
const [customTagsByFile, setCustomTagsByFile] = useState({});
|
||||||
const [paramsByStreamId, setParamsByStreamId] = useState(new Map());
|
const [paramsByStreamId, setParamsByStreamId] = useState(new Map());
|
||||||
@ -444,7 +444,7 @@ function App() {
|
|||||||
const usingPreviewFile = !!previewFilePath;
|
const usingPreviewFile = !!previewFilePath;
|
||||||
const effectiveFilePath = previewFilePath || filePath;
|
const effectiveFilePath = previewFilePath || filePath;
|
||||||
const fileUri = useMemo(() => {
|
const fileUri = useMemo(() => {
|
||||||
if (!effectiveFilePath) return '';
|
if (!effectiveFilePath) return ''; // Setting video src="" prevents memory leak in chromium
|
||||||
const uri = filePathToUrl(effectiveFilePath);
|
const uri = filePathToUrl(effectiveFilePath);
|
||||||
// https://github.com/mifi/lossless-cut/issues/1674
|
// https://github.com/mifi/lossless-cut/issues/1674
|
||||||
if (cacheBuster !== 0) {
|
if (cacheBuster !== 0) {
|
||||||
@ -458,10 +458,10 @@ function App() {
|
|||||||
const projectSuffix = 'proj.llc';
|
const projectSuffix = 'proj.llc';
|
||||||
const oldProjectSuffix = 'llc-edl.csv';
|
const oldProjectSuffix = 'llc-edl.csv';
|
||||||
// New LLC format can be stored along with input file or in working dir (customOutDir)
|
// 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:
|
// 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 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 projectFileSavePath = useMemo(() => getProjectFileSavePath(storeProjectInWorkingDir), [getProjectFileSavePath, storeProjectInWorkingDir]);
|
||||||
|
|
||||||
const currentSaveOperation = useMemo(() => {
|
const currentSaveOperation = useMemo(() => {
|
||||||
@ -664,7 +664,7 @@ function App() {
|
|||||||
|
|
||||||
const allFilesMeta = useMemo(() => ({
|
const allFilesMeta = useMemo(() => ({
|
||||||
...externalFilesMeta,
|
...externalFilesMeta,
|
||||||
[filePath]: mainFileMeta,
|
...(filePath ? { [filePath]: mainFileMeta } : {}),
|
||||||
}), [externalFilesMeta, filePath, mainFileMeta]);
|
}), [externalFilesMeta, filePath, mainFileMeta]);
|
||||||
|
|
||||||
// total number of streams for ALL files
|
// 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 { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, shouldShowWaveform, ffmpegExtractWindow, durationSafe });
|
||||||
|
|
||||||
const resetMergedOutFileName = useCallback(() => {
|
const resetMergedOutFileName = useCallback(() => {
|
||||||
|
if (fileFormat == null || filePath == null) return;
|
||||||
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath });
|
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath });
|
||||||
const outFileName = getSuffixedFileName(filePath, `cut-merged-${Date.now()}${ext}`);
|
const outFileName = getSuffixedFileName(filePath, `cut-merged-${Date.now()}${ext}`);
|
||||||
setMergedOutFileName(outFileName);
|
setMergedOutFileName(outFileName);
|
||||||
@ -770,7 +771,7 @@ function App() {
|
|||||||
setRotation(360);
|
setRotation(360);
|
||||||
setCutProgress(undefined);
|
setCutProgress(undefined);
|
||||||
setStartTimeOffset(0);
|
setStartTimeOffset(0);
|
||||||
setFilePath(''); // Setting video src="" prevents memory leak in chromium
|
setFilePath(undefined);
|
||||||
setExternalFilesMeta({});
|
setExternalFilesMeta({});
|
||||||
setCustomTagsByFile({});
|
setCustomTagsByFile({});
|
||||||
setParamsByStreamId(new Map());
|
setParamsByStreamId(new Map());
|
||||||
@ -1173,16 +1174,17 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [cleanupFilesWithDialog, isFileOpened, setWorking]);
|
}, [cleanupFilesWithDialog, isFileOpened, setWorking]);
|
||||||
|
|
||||||
const generateOutSegFileNames = useCallback(({ segments = segmentsToExport, template }) => (
|
const generateOutSegFileNames = useCallback(({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
|
||||||
generateOutSegFileNamesRaw({ segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding })
|
if (fileFormat == null || outputDir == null || filePath == null) throw new Error();
|
||||||
), [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);
|
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 closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []);
|
||||||
|
|
||||||
const willMerge = segmentsToExport.length > 1 && autoMerge;
|
const willMerge = segmentsToExport.length > 1 && autoMerge;
|
||||||
|
|
||||||
const mergedOutFilePath = useMemo(() => (
|
const mergedOutFilePath = useMemo(() => (
|
||||||
getOutPath({ customOutDir, filePath, fileName: mergedOutFileName })
|
mergedOutFileName != null ? getOutPath({ customOutDir, filePath, fileName: mergedOutFileName }) : undefined
|
||||||
), [customOutDir, filePath, mergedOutFileName]);
|
), [customOutDir, filePath, mergedOutFileName]);
|
||||||
|
|
||||||
const onExportConfirm = useCallback(async () => {
|
const onExportConfirm = useCallback(async () => {
|
||||||
@ -1351,6 +1353,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const currentTime = getRelevantTime();
|
const currentTime = getRelevantTime();
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
|
if (video == null) throw new Error();
|
||||||
const useFffmpeg = usingPreviewFile || captureFrameMethod === 'ffmpeg';
|
const useFffmpeg = usingPreviewFile || captureFrameMethod === 'ffmpeg';
|
||||||
const outPath = useFffmpeg
|
const outPath = useFffmpeg
|
||||||
? await captureFrameFromFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, quality: captureFrameQuality })
|
? await captureFrameFromFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, quality: captureFrameQuality })
|
||||||
@ -1376,10 +1379,10 @@ function App() {
|
|||||||
|
|
||||||
setCutProgress(0);
|
setCutProgress(0);
|
||||||
|
|
||||||
let lastOutPath;
|
let lastOutPath: string | undefined;
|
||||||
let totalProgress = 0;
|
let totalProgress = 0;
|
||||||
|
|
||||||
const onProgress = (progress) => {
|
const onProgress = (progress: number) => {
|
||||||
totalProgress += progress;
|
totalProgress += progress;
|
||||||
setCutProgress(totalProgress / segments.length);
|
setCutProgress(totalProgress / segments.length);
|
||||||
};
|
};
|
||||||
@ -1387,10 +1390,11 @@ function App() {
|
|||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
const { start, end } = segment;
|
const { start, end } = segment;
|
||||||
|
if (filePath == null) throw new Error();
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// 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 });
|
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) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -1693,8 +1697,8 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
setWorking({ text: i18n.t('Extracting all streams') });
|
setWorking({ text: i18n.t('Extracting all streams') });
|
||||||
setStreamsSelectorShown(false);
|
setStreamsSelectorShown(false);
|
||||||
const extractedPaths = await extractStreams({ customOutDir, filePath, streams: mainCopiedStreams, enableOverwriteOutput });
|
const [firstExtractedPath] = 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') });
|
if (!hideAllNotifications && firstExtractedPath != null) openDirToast({ icon: 'success', filePath: firstExtractedPath, text: i18n.t('All streams have been extracted as separate files') });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof RefuseOverwriteError) {
|
if (err instanceof RefuseOverwriteError) {
|
||||||
showRefuseToOverwrite();
|
showRefuseToOverwrite();
|
||||||
@ -1982,8 +1986,9 @@ function App() {
|
|||||||
const showIncludeExternalStreamsDialog = useCallback(async () => {
|
const showIncludeExternalStreamsDialog = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { canceled, filePaths } = await showOpenDialog({ properties: ['openFile'] });
|
const { canceled, filePaths } = await showOpenDialog({ properties: ['openFile'] });
|
||||||
if (canceled || filePaths.length === 0) return;
|
const [firstFilePath] = filePaths;
|
||||||
await addStreamSourceFile(filePaths[0]);
|
if (canceled || firstFilePath == null) return;
|
||||||
|
await addStreamSourceFile(firstFilePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
}
|
}
|
||||||
@ -2008,7 +2013,9 @@ function App() {
|
|||||||
|
|
||||||
const onEditSegmentTags = useCallback((index: number) => {
|
const onEditSegmentTags = useCallback((index: number) => {
|
||||||
setEditingSegmentTagsSegmentIndex(index);
|
setEditingSegmentTagsSegmentIndex(index);
|
||||||
setEditingSegmentTags(getSegmentTags(apparentCutSegments[index]));
|
const seg = apparentCutSegments[index];
|
||||||
|
if (seg == null) throw new Error();
|
||||||
|
setEditingSegmentTags(getSegmentTags(seg));
|
||||||
}, [apparentCutSegments]);
|
}, [apparentCutSegments]);
|
||||||
|
|
||||||
const editCurrentSegmentTags = useCallback(() => {
|
const editCurrentSegmentTags = useCallback(() => {
|
||||||
@ -2219,8 +2226,8 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
setWorking({ text: i18n.t('Extracting track') });
|
setWorking({ text: i18n.t('Extracting track') });
|
||||||
// setStreamsSelectorShown(false);
|
// setStreamsSelectorShown(false);
|
||||||
const extractedPaths = await extractStreams({ customOutDir, filePath, streams: mainStreams.filter((s) => s.index === index), enableOverwriteOutput });
|
const [firstExtractedPath] = 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') });
|
if (!hideAllNotifications && firstExtractedPath != null) openDirToast({ icon: 'success', filePath: firstExtractedPath, text: i18n.t('Track has been extracted') });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof RefuseOverwriteError) {
|
if (err instanceof RefuseOverwriteError) {
|
||||||
showRefuseToOverwrite();
|
showRefuseToOverwrite();
|
||||||
|
@ -5,8 +5,10 @@ import { FaAngleRight, FaFile } from 'react-icons/fa';
|
|||||||
import useContextMenu from '../hooks/useContextMenu';
|
import useContextMenu from '../hooks/useContextMenu';
|
||||||
import { primaryTextColor } from '../colors';
|
import { primaryTextColor } from '../colors';
|
||||||
|
|
||||||
const BatchFile = memo(({ path, isOpen, isSelected, name, onSelect, onDelete }) => {
|
const BatchFile = memo(({ path, isOpen, isSelected, name, onSelect, onDelete }: {
|
||||||
const ref = useRef();
|
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 { t } = useTranslation();
|
||||||
const contextMenuTemplate = useMemo(() => [
|
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)')} />
|
<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)')} />
|
<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 Swal from '../swal';
|
||||||
import HighlightedText from './HighlightedText';
|
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 useUserSettings from '../hooks/useUserSettings';
|
||||||
import Switch from './Switch';
|
import Switch from './Switch';
|
||||||
import Select from './Select';
|
import Select from './Select';
|
||||||
import TextInput from './TextInput';
|
import TextInput from './TextInput';
|
||||||
|
import { SegmentToExport } from '../types';
|
||||||
|
|
||||||
const ReactSwal = withReactContent(Swal);
|
const ReactSwal = withReactContent(Swal);
|
||||||
|
|
||||||
@ -23,16 +24,18 @@ const formatVariable = (variable) => `\${${variable}}`;
|
|||||||
|
|
||||||
const extVar = formatVariable('EXT');
|
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 { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding } = useUserSettings();
|
||||||
|
|
||||||
const [text, setText] = useState(outSegTemplate);
|
const [text, setText] = useState(outSegTemplate);
|
||||||
const [debouncedText] = useDebounce(text, 500);
|
const [debouncedText] = useDebounce(text, 500);
|
||||||
const [validText, setValidText] = useState();
|
const [validText, setValidText] = useState<string>();
|
||||||
const [outSegProblems, setOutSegProblems] = useState({ error: undefined, sameAsInputFileNameWarning: false });
|
const [outSegProblems, setOutSegProblems] = useState<{ error?: string, sameAsInputFileNameWarning?: boolean }>({ error: undefined, sameAsInputFileNameWarning: false });
|
||||||
const [outSegFileNames, setOutSegFileNames] = useState();
|
const [outSegFileNames, setOutSegFileNames] = useState<string[]>();
|
||||||
const [shown, setShown] = useState();
|
const [shown, setShown] = useState<boolean>();
|
||||||
const inputRef = useRef();
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@ -48,21 +51,25 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
|
|||||||
setValidText(outSegs.outSegProblems.error == null ? debouncedText : undefined);
|
setValidText(outSegs.outSegProblems.error == null ? debouncedText : undefined);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setValidText();
|
setValidText(undefined);
|
||||||
setOutSegProblems({ error: err.message });
|
setOutSegProblems({ error: err instanceof Error ? err.message : String(err) });
|
||||||
}
|
}
|
||||||
}, [debouncedText, generateOutSegFileNames, t]);
|
}, [debouncedText, generateOutSegFileNames, t]);
|
||||||
|
|
||||||
// eslint-disable-next-line no-template-curly-in-string
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
const isMissingExtension = validText != null && !validText.endsWith(extVar);
|
const isMissingExtension = validText != null && !validText.endsWith(extVar);
|
||||||
|
|
||||||
const onAllSegmentsPreviewPress = () => ReactSwal.fire({
|
const onAllSegmentsPreviewPress = useCallback(() => {
|
||||||
title: t('Resulting segment file names', { count: outSegFileNames.length }),
|
if (outSegFileNames == null) return;
|
||||||
html: (
|
ReactSwal.fire({
|
||||||
<div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}>
|
title: t('Resulting segment file names', { count: outSegFileNames.length }),
|
||||||
{outSegFileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)}
|
html: (
|
||||||
</div>
|
<div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}>
|
||||||
) });
|
{outSegFileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [outSegFileNames, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (validText != null) setOutSegTemplate(validText);
|
if (validText != null) setOutSegTemplate(validText);
|
||||||
@ -83,12 +90,14 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
|
|||||||
|
|
||||||
const onTextChange = useCallback((e) => setText(e.target.value), []);
|
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 onVariableClick = useCallback((variable) => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
const startPos = input.selectionStart;
|
const startPos = input!.selectionStart;
|
||||||
const endPos = input.selectionEnd;
|
const endPos = input!.selectionEnd;
|
||||||
|
if (startPos == null || endPos == null) return;
|
||||||
|
|
||||||
const newValue = `${text.slice(0, startPos)}${`${formatVariable(variable)}${text.slice(endPos)}`}`;
|
const newValue = `${text.slice(0, startPos)}${`${formatVariable(variable)}${text.slice(endPos)}`}`;
|
||||||
setText(newValue);
|
setText(newValue);
|
||||||
@ -114,7 +123,7 @@ const OutSegTemplateEditor = memo(({ outSegTemplate, setOutSegTemplate, generate
|
|||||||
{outSegFileNames != null && <Button height={20} onClick={onAllSegmentsPreviewPress} marginLeft={5}>{t('Preview')}</Button>}
|
{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('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>
|
||||||
|
|
||||||
<div style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center', marginBottom: '.7em' }}>
|
<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 && (
|
{hasTextNumericPaddedValue && (
|
||||||
<div style={{ marginBottom: '.3em' }}>
|
<div style={{ marginBottom: '.3em' }}>
|
||||||
<Select value={outputFileNameMinZeroPadding} onChange={(e) => setOutputFileNameMinZeroPadding(parseInt(e.target.value, 10))} style={{ marginRight: '1em', fontSize: '1em' }}>
|
<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>
|
</Select>
|
||||||
Minimum numeric padded length
|
Minimum numeric padded length
|
||||||
</div>
|
</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'] }];
|
else if (type === 'llc') filters = [{ name: i18n.t('LosslessCut project'), extensions: ['llc'] }];
|
||||||
|
|
||||||
const { canceled, filePaths } = await showOpenDialog({ properties: ['openFile'], filters });
|
const { canceled, filePaths } = await showOpenDialog({ properties: ['openFile'], filters });
|
||||||
if (canceled || filePaths.length === 0) return [];
|
const [firstFilePath] = filePaths;
|
||||||
return readEdlFile({ type, path: filePaths[0], fps });
|
if (canceled || firstFilePath == null) return [];
|
||||||
|
return readEdlFile({ type, path: firstFilePath, fps });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportEdlFile({ type, cutSegments, customOutDir, filePath, getFrameCount }: {
|
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 outPaths = await pMap(streams, async ({ index, codec_name: codec, codec_type: type }) => {
|
||||||
const ext = codec || 'bin';
|
const ext = codec || 'bin';
|
||||||
const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` });
|
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();
|
if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError();
|
||||||
|
|
||||||
streamArgs = [
|
streamArgs = [
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { useEffect } from 'react';
|
import { RefObject, useEffect } from 'react';
|
||||||
|
import type { MenuItem, MenuItemConstructorOptions } from 'electron';
|
||||||
|
|
||||||
import useNativeMenu from './useNativeMenu';
|
import useNativeMenu from './useNativeMenu';
|
||||||
|
|
||||||
// https://github.com/transflow/use-electron-context-menu
|
// https://github.com/transflow/use-electron-context-menu
|
||||||
export default function useContextMenu(
|
export default function useContextMenu(
|
||||||
ref,
|
ref: RefObject<HTMLElement>,
|
||||||
template,
|
template: (MenuItemConstructorOptions | MenuItem)[],
|
||||||
options = {},
|
|
||||||
) {
|
) {
|
||||||
const { openMenu, closeMenu } = useNativeMenu(template, options);
|
const { openMenu, closeMenu } = useNativeMenu(template);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
@ -13,7 +13,7 @@ const { join, resolve, dirname } = window.require('path');
|
|||||||
const { pathExists } = window.require('fs-extra');
|
const { pathExists } = window.require('fs-extra');
|
||||||
const { writeFile, mkdir } = window.require('fs/promises');
|
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;
|
if (!chapters || chapters.length === 0) return undefined;
|
||||||
|
|
||||||
const path = join(outDir, `ffmetadata-${Date.now()}.txt`);
|
const path = join(outDir, `ffmetadata-${Date.now()}.txt`);
|
||||||
@ -26,8 +26,8 @@ async function writeChaptersFfmetadata(outDir, chapters) {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMovFlags({ preserveMovData, movFastStart }) {
|
function getMovFlags({ preserveMovData, movFastStart }: { preserveMovData: boolean, movFastStart: boolean }) {
|
||||||
const flags = [];
|
const flags: string[] = [];
|
||||||
|
|
||||||
// https://video.stackexchange.com/a/26084/29486
|
// https://video.stackexchange.com/a/26084/29486
|
||||||
// https://github.com/mifi/lossless-cut/issues/331#issuecomment-623401794
|
// https://github.com/mifi/lossless-cut/issues/331#issuecomment-623401794
|
||||||
@ -52,7 +52,7 @@ function getMatroskaFlags() {
|
|||||||
|
|
||||||
const getChaptersInputArgs = (ffmetadataPath) => (ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []);
|
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 });
|
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 {
|
try {
|
||||||
let inputArgs = [];
|
let inputArgs: string[] = [];
|
||||||
let inputIndex = 0;
|
let inputIndex = 0;
|
||||||
|
|
||||||
// Keep track of input index to be used later
|
// Keep track of input index to be used later
|
||||||
// eslint-disable-next-line no-inner-declarations
|
// eslint-disable-next-line no-inner-declarations
|
||||||
function addInput(args) {
|
function addInput(args: string[]) {
|
||||||
inputArgs = [...inputArgs, ...args];
|
inputArgs = [...inputArgs, ...args];
|
||||||
const retIndex = inputIndex;
|
const retIndex = inputIndex;
|
||||||
inputIndex += 1;
|
inputIndex += 1;
|
||||||
@ -245,10 +245,8 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
const mapStreamsArgs = getMapStreamsArgs({ copyFileStreams: copyFileStreamsFiltered, allFilesMeta, outFormat });
|
const mapStreamsArgs = getMapStreamsArgs({ copyFileStreams: copyFileStreamsFiltered, allFilesMeta, outFormat });
|
||||||
|
|
||||||
const customParamsArgs = (() => {
|
const customParamsArgs = (() => {
|
||||||
const ret = [];
|
const ret: string[] = [];
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
for (const [fileId, fileParams] of paramsByStreamId.entries()) {
|
for (const [fileId, fileParams] of paramsByStreamId.entries()) {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
for (const [streamId, streamParams] of fileParams.entries()) {
|
for (const [streamId, streamParams] of fileParams.entries()) {
|
||||||
const outputIndex = mapInputStreamIndexToOutputIndex(fileId, parseInt(streamId, 10));
|
const outputIndex = mapInputStreamIndexToOutputIndex(fileId, parseInt(streamId, 10));
|
||||||
if (outputIndex != null) {
|
if (outputIndex != null) {
|
||||||
@ -269,7 +267,6 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
// custom stream metadata tags
|
// custom stream metadata tags
|
||||||
const customTags = streamParams.get('customTags');
|
const customTags = streamParams.get('customTags');
|
||||||
if (customTags != null) {
|
if (customTags != null) {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
for (const [tag, value] of Object.entries(customTags)) {
|
for (const [tag, value] of Object.entries(customTags)) {
|
||||||
ret.push(`-metadata:s:${outputIndex}`, `${tag}=${value}`);
|
ret.push(`-metadata:s:${outputIndex}`, `${tag}=${value}`);
|
||||||
}
|
}
|
||||||
@ -285,7 +282,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
// No progress if we set loglevel warning :(
|
// No progress if we set loglevel warning :(
|
||||||
// '-loglevel', 'warning',
|
// '-loglevel', 'warning',
|
||||||
|
|
||||||
...getOutputPlaybackRateArgs(outputPlaybackRate),
|
...getOutputPlaybackRateArgs(),
|
||||||
|
|
||||||
...inputArgs,
|
...inputArgs,
|
||||||
|
|
||||||
@ -325,7 +322,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
logStdoutStderr(result);
|
logStdoutStderr(result);
|
||||||
|
|
||||||
await transferTimestamps({ inPath: filePath, outPath, cutFrom, cutTo, treatInputFileModifiedTimeAsStart, duration: isDurationValid(videoDuration) ? videoDuration : undefined, treatOutputFileModifiedTimeAsStart });
|
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 ({
|
const cutMultiple = useCallback(async ({
|
||||||
outputDir, customOutDir, segments, outSegFileNames, videoDuration, rotation, detectedFps,
|
outputDir, customOutDir, segments, outSegFileNames, videoDuration, rotation, detectedFps,
|
||||||
@ -388,6 +385,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
// eslint-disable-next-line no-shadow
|
// eslint-disable-next-line no-shadow
|
||||||
async function cutEncodeSmartPartWrapper({ cutFrom, cutTo, outPath }) {
|
async function cutEncodeSmartPartWrapper({ cutFrom, cutTo, outPath }) {
|
||||||
if (await shouldSkipExistingFile(outPath)) return;
|
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 });
|
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 dataUriToBuffer from 'data-uri-to-buffer';
|
||||||
import pMap from 'p-map';
|
import pMap from 'p-map';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import type * as FsPromises from 'fs/promises';
|
||||||
|
|
||||||
import { getSuffixedOutPath, getOutDir, transferTimestamps, getSuffixedFileName, getOutPath, escapeRegExp, fsOperationWithRetry } from '../util';
|
import { getSuffixedOutPath, getOutDir, transferTimestamps, getSuffixedFileName, getOutPath, escapeRegExp, fsOperationWithRetry } from '../util';
|
||||||
import { getNumDigits } from '../segments';
|
import { getNumDigits } from '../segments';
|
||||||
@ -8,7 +9,7 @@ import { getNumDigits } from '../segments';
|
|||||||
import { captureFrame as ffmpegCaptureFrame, captureFrames as ffmpegCaptureFrames } from '../ffmpeg';
|
import { captureFrame as ffmpegCaptureFrame, captureFrames as ffmpegCaptureFrames } from '../ffmpeg';
|
||||||
|
|
||||||
const mime = window.require('mime-types');
|
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) {
|
function getFrameFromVideo(video, format, quality) {
|
||||||
@ -16,7 +17,7 @@ function getFrameFromVideo(video, format, quality) {
|
|||||||
canvas.width = video.videoWidth;
|
canvas.width = video.videoWidth;
|
||||||
canvas.height = video.videoHeight;
|
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);
|
const dataUri = canvas.toDataURL(`image/${format}`, quality);
|
||||||
|
|
||||||
@ -24,8 +25,10 @@ function getFrameFromVideo(video, format, quality) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default ({ formatTimecode, treatOutputFileModifiedTimeAsStart }) => {
|
export default ({ formatTimecode, treatOutputFileModifiedTimeAsStart }) => {
|
||||||
const captureFramesRange = useCallback(async ({ customOutDir, filePath, fps, fromTime, toTime, estimatedMaxNumFiles, captureFormat, quality, filter, onProgress, outputTimestamps }) => {
|
const captureFramesRange = useCallback(async ({ customOutDir, filePath, fps, fromTime, toTime, estimatedMaxNumFiles, captureFormat, quality, filter, onProgress, outputTimestamps }: {
|
||||||
const getSuffix = (prefix) => `${prefix}.${captureFormat}`;
|
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) {
|
if (!outputTimestamps) {
|
||||||
const numDigits = getNumDigits(estimatedMaxNumFiles);
|
const numDigits = getNumDigits(estimatedMaxNumFiles);
|
||||||
@ -48,21 +51,21 @@ export default ({ formatTimecode, treatOutputFileModifiedTimeAsStart }) => {
|
|||||||
const files = await readdir(outDir);
|
const files = await readdir(outDir);
|
||||||
|
|
||||||
// https://github.com/mifi/lossless-cut/issues/1139
|
// 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 escapedRegexp = escapeRegExp(getSuffixedFileName(filePath, tmpSuffix));
|
||||||
const regexp = `^${escapedRegexp}(\\d+)`;
|
const regexp = `^${escapedRegexp}(\\d+)`;
|
||||||
const match = fileName.match(new RegExp(regexp));
|
const match = fileName.match(new RegExp(regexp));
|
||||||
if (!match) return undefined;
|
if (!match) return [];
|
||||||
const frameNum = parseInt(match[1], 10);
|
const frameNum = parseInt(match[1]!, 10);
|
||||||
if (Number.isNaN(frameNum) || frameNum < 0) return undefined;
|
if (Number.isNaN(frameNum) || frameNum < 0) return [];
|
||||||
return { fileName, frameNum };
|
return [{ fileName, frameNum }];
|
||||||
}).filter((it) => it != null);
|
});
|
||||||
|
|
||||||
console.log('Renaming temp files...');
|
console.log('Renaming temp files...');
|
||||||
const outPaths = await pMap(matches, async ({ fileName, frameNum }) => {
|
const outPaths = await pMap(matches, async ({ fileName, frameNum }) => {
|
||||||
const duration = formatTimecode({ seconds: fromTime + (frameNum / fps), fileNameFriendly: true });
|
const duration = formatTimecode({ seconds: fromTime + (frameNum / fps), fileNameFriendly: true });
|
||||||
const renameFromPath = getOutPath({ customOutDir, filePath, fileName });
|
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));
|
await fsOperationWithRetry(async () => rename(renameFromPath, renameToPath));
|
||||||
return renameToPath;
|
return renameToPath;
|
||||||
}, { concurrency: 1 });
|
}, { concurrency: 1 });
|
||||||
@ -70,7 +73,9 @@ export default ({ formatTimecode, treatOutputFileModifiedTimeAsStart }) => {
|
|||||||
return outPaths[0];
|
return outPaths[0];
|
||||||
}, [formatTimecode]);
|
}, [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 time = formatTimecode({ seconds: fromTime, fileNameFriendly: true });
|
||||||
const nameSuffix = `${time}.${captureFormat}`;
|
const nameSuffix = `${time}.${captureFormat}`;
|
||||||
const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix });
|
const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix });
|
||||||
@ -80,7 +85,9 @@ export default ({ formatTimecode, treatOutputFileModifiedTimeAsStart }) => {
|
|||||||
return outPath;
|
return outPath;
|
||||||
}, [formatTimecode, treatOutputFileModifiedTimeAsStart]);
|
}, [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 buf = getFrameFromVideo(video, captureFormat, quality);
|
||||||
|
|
||||||
const ext = mime.extension(buf.type);
|
const ext = mime.extension(buf.type);
|
@ -1,20 +1,22 @@
|
|||||||
|
import type { Menu as MenuType, MenuItemConstructorOptions, MenuItem } from 'electron';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
// TODO pull out?
|
// TODO pull out?
|
||||||
const remote = window.require('@electron/remote');
|
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://github.com/transflow/use-electron-context-menu
|
||||||
// https://www.electronjs.org/docs/latest/api/menu-item
|
// https://www.electronjs.org/docs/latest/api/menu-item
|
||||||
export default function useNativeMenu(
|
export default function useNativeMenu(
|
||||||
template,
|
template: (MenuItemConstructorOptions | MenuItem)[],
|
||||||
options = {},
|
options: { x?: number, y?: number, onContext?: (e: MouseEvent) => void, onClose?: () => void } = {},
|
||||||
) {
|
) {
|
||||||
const menu = useMemo(() => Menu.buildFromTemplate(template), [template]);
|
const menu = useMemo(() => Menu.buildFromTemplate(template), [template]);
|
||||||
|
|
||||||
const { x, y, onContext, onClose } = options;
|
const { x, y, onContext, onClose } = options;
|
||||||
|
|
||||||
const openMenu = useCallback((e) => {
|
const openMenu = useCallback((e: MouseEvent) => {
|
||||||
menu.popup({
|
menu.popup({
|
||||||
window: remote.getCurrentWindow(),
|
window: remote.getCurrentWindow(),
|
||||||
x,
|
x,
|
@ -21,7 +21,7 @@ const { blackDetect, silenceDetect } = remote.require('./ffmpeg');
|
|||||||
|
|
||||||
|
|
||||||
export default ({ filePath, workingRef, setWorking, setCutProgress, videoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly }: {
|
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
|
// Segment related state
|
||||||
const segCounterRef = useRef(0);
|
const segCounterRef = useRef(0);
|
||||||
@ -266,6 +266,7 @@ export default ({ filePath, workingRef, setWorking, setCutProgress, videoStream,
|
|||||||
|
|
||||||
async function align(key) {
|
async function align(key) {
|
||||||
const time = newSegment[key];
|
const time = newSegment[key];
|
||||||
|
if (filePath == null) throw new Error();
|
||||||
const keyframe = await findKeyframeNearTime({ filePath, streamIndex: videoStream.index, time, mode });
|
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}`);
|
if (keyframe == null) throw new Error(`Cannot find any keyframe within 60 seconds of frame ${time}`);
|
||||||
newSegment[key] = keyframe;
|
newSegment[key] = keyframe;
|
||||||
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { MotionConfig } from 'framer-motion';
|
import { MotionConfig } from 'framer-motion';
|
||||||
import { enableMapSet } from 'immer';
|
import { enableMapSet } from 'immer';
|
||||||
import * as Electron from 'electron';
|
import * as Electron from 'electron';
|
||||||
|
import Remote from '@electron/remote';
|
||||||
|
|
||||||
import 'sweetalert2/dist/sweetalert2.css';
|
import 'sweetalert2/dist/sweetalert2.css';
|
||||||
|
|
||||||
@ -28,7 +29,10 @@ import './main.css';
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
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 sortBy from 'lodash/sortBy';
|
||||||
import minBy from 'lodash/minBy';
|
import minBy from 'lodash/minBy';
|
||||||
import maxBy from 'lodash/maxBy';
|
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;
|
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;
|
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');
|
export const sortSegments = <T>(segments: T[]) => sortBy(segments, 'start');
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ export function hasAnySegmentOverlap(sortedSegments) {
|
|||||||
return overlappingGroups.length > 0;
|
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 (sortedCutSegments.length === 0) return undefined;
|
||||||
|
|
||||||
if (hasAnySegmentOverlap(sortedCutSegments)) return undefined;
|
if (hasAnySegmentOverlap(sortedCutSegments)) return undefined;
|
||||||
@ -137,7 +137,7 @@ export function invertSegments(sortedCutSegments, includeFirstSegment: boolean,
|
|||||||
const ret: InverseSegment[] = [];
|
const ret: InverseSegment[] = [];
|
||||||
|
|
||||||
if (includeFirstSegment) {
|
if (includeFirstSegment) {
|
||||||
const firstSeg = sortedCutSegments[0];
|
const firstSeg = sortedCutSegments[0]!;
|
||||||
if (firstSeg.start > 0) {
|
if (firstSeg.start > 0) {
|
||||||
ret.push({
|
ret.push({
|
||||||
start: 0,
|
start: 0,
|
||||||
@ -149,7 +149,7 @@ export function invertSegments(sortedCutSegments, includeFirstSegment: boolean,
|
|||||||
|
|
||||||
sortedCutSegments.forEach((cutSegment, i) => {
|
sortedCutSegments.forEach((cutSegment, i) => {
|
||||||
if (i === 0) return;
|
if (i === 0) return;
|
||||||
const previousSeg = sortedCutSegments[i - 1];
|
const previousSeg = sortedCutSegments[i - 1]!;
|
||||||
const inverted: InverseSegment = {
|
const inverted: InverseSegment = {
|
||||||
start: previousSeg.end,
|
start: previousSeg.end,
|
||||||
end: cutSegment.start,
|
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 {
|
export interface StateSegment extends SegmentBase {
|
||||||
name: string;
|
name: string;
|
||||||
segId: string;
|
segId: string;
|
||||||
segColorIndex?: number | undefined;
|
segColorIndex?: number | undefined;
|
||||||
tags?: Record<string, string> | undefined;
|
tags?: SegmentTags | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Segment extends SegmentBase {
|
export interface Segment extends SegmentBase {
|
||||||
name?: string,
|
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,
|
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 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';
|
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 ky from 'ky';
|
||||||
import prettyBytes from 'pretty-bytes';
|
import prettyBytes from 'pretty-bytes';
|
||||||
import sortBy from 'lodash/sortBy';
|
import sortBy from 'lodash/sortBy';
|
||||||
import pRetry from 'p-retry';
|
import pRetry, { Options } from 'p-retry';
|
||||||
import { ExecaError } from 'execa';
|
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 isDev from './isDev';
|
||||||
import Swal, { toast } from './swal';
|
import Swal, { toast } from './swal';
|
||||||
import { ffmpegExtractWindow } from './util/constants';
|
import { ffmpegExtractWindow } from './util/constants';
|
||||||
|
|
||||||
const { dirname, parse: parsePath, join, extname, isAbsolute, resolve, basename } = window.require('path');
|
const { dirname, parse: parsePath, join, extname, isAbsolute, resolve, basename }: PlatformPath = window.require('path');
|
||||||
const fsExtra = window.require('fs-extra');
|
const fsExtra: typeof FsExtra = window.require('fs-extra');
|
||||||
const { stat, lstat, readdir, utimes, unlink } = window.require('fs/promises');
|
const { stat, lstat, readdir, utimes, unlink }: typeof FsPromises = window.require('fs/promises');
|
||||||
const os = window.require('os');
|
const os: typeof Os = window.require('os');
|
||||||
const { ipcRenderer } = window.require('electron');
|
const { ipcRenderer } = window.require('electron');
|
||||||
const remote = window.require('@electron/remote');
|
const remote = window.require('@electron/remote');
|
||||||
|
|
||||||
@ -27,9 +31,10 @@ export function getFileDir(filePath?: string) {
|
|||||||
return filePath ? dirname(filePath) : undefined;
|
return filePath ? dirname(filePath) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOutDir(customOutDir?: string, filePath?: string) {
|
export function getOutDir<T1 extends string | undefined, T2 extends string | undefined>(customOutDir?: T1, filePath?: T2): T1 extends string ? string : T2 extends string ? string : undefined;
|
||||||
if (customOutDir) return customOutDir;
|
export function getOutDir(customOutDir?: string | undefined, filePath?: string | undefined) {
|
||||||
if (filePath) return getFileDir(filePath);
|
if (customOutDir != null) return customOutDir;
|
||||||
|
if (filePath != null) return getFileDir(filePath);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,15 +44,17 @@ function getFileBaseName(filePath?: string) {
|
|||||||
return parsed.name;
|
return parsed.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOutPath({ customOutDir, filePath, fileName }: { customOutDir?: string, filePath?: string, fileName?: string }) {
|
export function getOutPath<T extends string | undefined>(a: { customOutDir?: string, filePath?: T, fileName: string }): T extends string ? string : undefined;
|
||||||
if (!filePath) return 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);
|
return join(getOutDir(customOutDir, filePath), fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSuffixedFileName = (filePath: string | undefined, nameSuffix: string) => `${getFileBaseName(filePath)}-${nameSuffix}`;
|
export const getSuffixedFileName = (filePath: string | undefined, nameSuffix: string) => `${getFileBaseName(filePath)}-${nameSuffix}`;
|
||||||
|
|
||||||
export function getSuffixedOutPath({ customOutDir, filePath, nameSuffix }: { customOutDir?: string, filePath?: string, nameSuffix: string }) {
|
export function getSuffixedOutPath<T extends string | undefined>(a: { customOutDir?: string, filePath?: T, nameSuffix: string }): T extends string ? string : undefined;
|
||||||
if (!filePath) return 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) });
|
return getOutPath({ customOutDir, filePath, fileName: getSuffixedFileName(filePath, nameSuffix) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +107,7 @@ export async function dirExists(dirPath) {
|
|||||||
const testFailFsOperation = false;
|
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
|
// 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 () => {
|
return pRetry(async () => {
|
||||||
if (testFailFsOperation && Math.random() > 0.3) throw Object.assign(new Error('test delete failure'), { code: 'EPERM' });
|
if (testFailFsOperation && Math.random() > 0.3) throw Object.assign(new Error('test delete failure'), { code: 'EPERM' });
|
||||||
await operation();
|
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'
|
// 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'
|
// 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?: Options) => 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?: any) => fsOperationWithRetry(async () => utimes(path, atime, mtime), { ...options, onFailedAttempt: (error) => console.warn('Retrying utimes', path, error.attemptNumber) });
|
|
||||||
|
|
||||||
export const getFrameDuration = (fps?: number) => 1 / (fps ?? 30);
|
export const getFrameDuration = (fps?: number) => 1 / (fps ?? 30);
|
||||||
|
|
||||||
@ -192,7 +198,7 @@ export const arch = os.arch();
|
|||||||
export const isWindows = platform === 'win32';
|
export const isWindows = platform === 'win32';
|
||||||
export const isMac = platform === 'darwin';
|
export const isMac = platform === 'darwin';
|
||||||
|
|
||||||
export function getExtensionForFormat(format) {
|
export function getExtensionForFormat(format: string) {
|
||||||
const ext = {
|
const ext = {
|
||||||
matroska: 'mkv',
|
matroska: 'mkv',
|
||||||
ipod: 'm4a',
|
ipod: 'm4a',
|
||||||
@ -203,7 +209,9 @@ export function getExtensionForFormat(format) {
|
|||||||
return ext || format;
|
return ext || format;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }) {
|
export function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }: {
|
||||||
|
isCustomFormatSelected?: boolean, outFormat: string, filePath: string,
|
||||||
|
}) {
|
||||||
if (!isCustomFormatSelected) {
|
if (!isCustomFormatSelected) {
|
||||||
const ext = extname(filePath);
|
const ext = extname(filePath);
|
||||||
// QuickTime is quirky about the file extension of mov files (has to be .mov)
|
// 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 prefix = getSuffixedFileName(fp, html5ifiedPrefix);
|
||||||
|
|
||||||
const outDir = getOutDir(cod, fp);
|
const outDir = getOutDir(cod, fp);
|
||||||
|
if (outDir == null) throw new Error();
|
||||||
const dirEntries = await readdir(outDir);
|
const dirEntries = await readdir(outDir);
|
||||||
|
|
||||||
const html5ifiedDirEntries = dirEntries.filter((entry) => entry.startsWith(prefix));
|
const html5ifiedDirEntries = dirEntries.filter((entry) => entry.startsWith(prefix));
|
||||||
|
|
||||||
let matches: { entry: string, suffix: string }[] = [];
|
let matches: { entry: string, suffix?: string }[] = [];
|
||||||
suffixes.forEach((suffix) => {
|
suffixes.forEach((suffix) => {
|
||||||
const entryWithSuffix = html5ifiedDirEntries.find((entry) => new RegExp(`${suffix}\\..*$`).test(entry.replace(prefix, '')));
|
const entryWithSuffix = html5ifiedDirEntries.find((entry) => new RegExp(`${suffix}\\..*$`).test(entry.replace(prefix, '')));
|
||||||
if (entryWithSuffix) matches = [...matches, { entry: entryWithSuffix, suffix }];
|
if (entryWithSuffix) matches = [...matches, { entry: entryWithSuffix, suffix }];
|
||||||
@ -325,6 +334,7 @@ export async function checkAppPath() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pathSeg = pathMatch[1];
|
const pathSeg = pathMatch[1];
|
||||||
|
if (pathSeg == null) return;
|
||||||
if (pathSeg.startsWith(`57275${mf}.${llc}_`)) return;
|
if (pathSeg.startsWith(`57275${mf}.${llc}_`)) return;
|
||||||
// this will report the path and may return a msg
|
// this will report the path and may return a msg
|
||||||
const url = `https://losslesscut-analytics.mifi.no/${pathSeg.length}/${encodeURIComponent(btoa(pathSeg))}`;
|
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;
|
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[] = [];
|
const parts: string[] = [];
|
||||||
if (filePath) parts.push(basename(filePath));
|
if (filePath) parts.push(basename(filePath));
|
||||||
if (working) {
|
if (working) {
|
||||||
|
@ -1,22 +1,25 @@
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import lodashTemplate from 'lodash/template';
|
import lodashTemplate from 'lodash/template';
|
||||||
|
import { PlatformPath } from 'path';
|
||||||
|
|
||||||
import { isMac, isWindows, hasDuplicates, filenamify, getOutFileExtension } from '../util';
|
import { isMac, isWindows, hasDuplicates, filenamify, getOutFileExtension } from '../util';
|
||||||
import isDev from '../isDev';
|
import isDev from '../isDev';
|
||||||
import { getSegmentTags, formatSegNum } from '../segments';
|
import { getSegmentTags, formatSegNum } from '../segments';
|
||||||
import { Segment } from '../types';
|
import { SegmentToExport } from '../types';
|
||||||
|
|
||||||
|
|
||||||
export const segNumVariable = 'SEG_NUM';
|
export const segNumVariable = 'SEG_NUM';
|
||||||
export const segSuffixVariable = 'SEG_SUFFIX';
|
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;
|
let sameAsInputFileNameWarning = false;
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
for (const fileName of fileNames) {
|
for (const fileName of fileNames) {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
error = i18n.t('No file is loaded');
|
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 }: {
|
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 }) {
|
function generate({ template, forceSafeOutputFileName }) {
|
||||||
const epochMs = Date.now();
|
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
|
// 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)
|
// 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() {
|
function getSegSuffix() {
|
||||||
if (name) return `-${filenamifyOrNot(name)}`;
|
if (name) return `-${filenamifyOrNot(name)}`;
|
||||||
|
@ -106,7 +106,7 @@ export function getActiveDisposition(disposition) {
|
|||||||
return existingActiveDispositionEntry[0]; // return the key
|
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;
|
type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | undefined;
|
||||||
|
|
||||||
|
17
yarn.lock
17
yarn.lock
@ -1809,6 +1809,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/node@npm:^18.11.18":
|
||||||
version: 18.17.6
|
version: 18.17.6
|
||||||
resolution: "@types/node@npm:18.17.6"
|
resolution: "@types/node@npm:18.17.6"
|
||||||
@ -7699,6 +7708,7 @@ __metadata:
|
|||||||
"@tsconfig/vite-react": "npm:^3.0.0"
|
"@tsconfig/vite-react": "npm:^3.0.0"
|
||||||
"@types/eslint": "npm:^8"
|
"@types/eslint": "npm:^8"
|
||||||
"@types/lodash": "npm:^4.14.202"
|
"@types/lodash": "npm:^4.14.202"
|
||||||
|
"@types/node": "npm:18"
|
||||||
"@types/sortablejs": "npm:^1.15.0"
|
"@types/sortablejs": "npm:^1.15.0"
|
||||||
"@typescript-eslint/eslint-plugin": "npm:^6.12.0"
|
"@typescript-eslint/eslint-plugin": "npm:^6.12.0"
|
||||||
"@typescript-eslint/parser": "npm:^6.12.0"
|
"@typescript-eslint/parser": "npm:^6.12.0"
|
||||||
@ -11748,6 +11758,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"unique-filename@npm:^2.0.0":
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
resolution: "unique-filename@npm:2.0.1"
|
resolution: "unique-filename@npm:2.0.1"
|
||||||
|
Loading…
Reference in New Issue
Block a user