mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-21 18:02:35 +01:00
pull code out from App
also: - fix types - fix broken subtitlesByStreamIdRef - type cleanup choices - remove unneeded userSeekAbs - log when revoking urls
This commit is contained in:
parent
1c320f423d
commit
2dac700f40
@ -1,10 +1,8 @@
|
|||||||
import { memo, useEffect, useState, useCallback, useRef, useMemo, CSSProperties, ReactEventHandler } from 'react';
|
import { memo, useEffect, useState, useCallback, useRef, useMemo, CSSProperties, ReactEventHandler, FocusEventHandler } from 'react';
|
||||||
import { FaAngleLeft, FaWindowClose } from 'react-icons/fa';
|
import { FaAngleLeft, FaWindowClose } from 'react-icons/fa';
|
||||||
import { MdRotate90DegreesCcw } from 'react-icons/md';
|
import { MdRotate90DegreesCcw } from 'react-icons/md';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
import { ThemeProvider } from 'evergreen-ui';
|
import { ThemeProvider } from 'evergreen-ui';
|
||||||
import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this
|
|
||||||
import { useDebounce } from 'use-debounce';
|
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
@ -12,9 +10,6 @@ import screenfull from 'screenfull';
|
|||||||
import { IpcRendererEvent } from 'electron';
|
import { IpcRendererEvent } from 'electron';
|
||||||
|
|
||||||
import fromPairs from 'lodash/fromPairs';
|
import fromPairs from 'lodash/fromPairs';
|
||||||
import sortBy from 'lodash/sortBy';
|
|
||||||
import flatMap from 'lodash/flatMap';
|
|
||||||
import isEqual from 'lodash/isEqual';
|
|
||||||
import sum from 'lodash/sum';
|
import sum from 'lodash/sum';
|
||||||
import invariant from 'tiny-invariant';
|
import invariant from 'tiny-invariant';
|
||||||
import { SweetAlertOptions } from 'sweetalert2';
|
import { SweetAlertOptions } from 'sweetalert2';
|
||||||
@ -54,45 +49,52 @@ import Working from './components/Working';
|
|||||||
import OutputFormatSelect from './components/OutputFormatSelect';
|
import OutputFormatSelect from './components/OutputFormatSelect';
|
||||||
|
|
||||||
import { loadMifiLink, runStartupCheck } from './mifi';
|
import { loadMifiLink, runStartupCheck } from './mifi';
|
||||||
import { controlsBackground, darkModeTransition } from './colors';
|
import { darkModeTransition } from './colors';
|
||||||
import { getSegColor } from './util/colors';
|
import { getSegColor } from './util/colors';
|
||||||
import {
|
import {
|
||||||
getStreamFps, isCuttingStart, isCuttingEnd,
|
getStreamFps, isCuttingStart, isCuttingEnd,
|
||||||
readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails,
|
readFileMeta, getSmarterOutFormat,
|
||||||
extractStreams, setCustomFfPath as ffmpegSetCustomFfPath,
|
extractStreams, setCustomFfPath as ffmpegSetCustomFfPath,
|
||||||
isIphoneHevc, isProblematicAvc1, tryMapChaptersToEdl,
|
isIphoneHevc, isProblematicAvc1, tryMapChaptersToEdl,
|
||||||
getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrackVtt,
|
getDuration, getTimecodeFromStreams, createChaptersFromSegments,
|
||||||
RefuseOverwriteError, abortFfmpegs, extractSubtitleTrackToSegments,
|
RefuseOverwriteError, extractSubtitleTrackToSegments,
|
||||||
} from './ffmpeg';
|
} from './ffmpeg';
|
||||||
import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, isStreamThumbnail, getSubtitleStreams, getVideoTrackForStreamIndex, getAudioTrackForStreamIndex, enableVideoTrack, enableAudioTrack } from './util/streams';
|
import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, getSubtitleStreams, getVideoTrackForStreamIndex, getAudioTrackForStreamIndex, enableVideoTrack, enableAudioTrack } from './util/streams';
|
||||||
import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore';
|
import { exportEdlFile, readEdlFile, loadLlcProject, askForEdlImport } from './edlStore';
|
||||||
import { formatYouTube, getFrameCountRaw, formatTsv } from './edlFormats';
|
import { formatYouTube, getFrameCountRaw, formatTsv } from './edlFormats';
|
||||||
import {
|
import {
|
||||||
getOutPath, getSuffixedOutPath, handleError, getOutDir,
|
getOutPath, getSuffixedOutPath, handleError, getOutDir,
|
||||||
isStoreBuild, dragPreventer,
|
isStoreBuild, dragPreventer,
|
||||||
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
|
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
|
||||||
deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType,
|
deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType,
|
||||||
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, getFrameDuration, isExecaError, getStdioString,
|
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, isExecaError, getStdioString,
|
||||||
isMuxNotSupported,
|
isMuxNotSupported,
|
||||||
getDownloadMediaOutPath,
|
getDownloadMediaOutPath,
|
||||||
} from './util';
|
} from './util';
|
||||||
import { toast, errorToast } from './swal';
|
import { toast, errorToast, showPlaybackFailedMessage } from './swal';
|
||||||
import { formatDuration, parseDuration } from './util/duration';
|
|
||||||
import { adjustRate } from './util/rate-calculator';
|
import { adjustRate } from './util/rate-calculator';
|
||||||
import { askExtractFramesAsImages } from './dialogs/extractFrames';
|
import { askExtractFramesAsImages } from './dialogs/extractFrames';
|
||||||
import { askForHtml5ifySpeed } from './dialogs/html5ify';
|
import { askForHtml5ifySpeed } from './dialogs/html5ify';
|
||||||
import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl } from './dialogs';
|
import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, CleanupChoicesType } from './dialogs';
|
||||||
import { openSendReportDialog } from './reporting';
|
import { openSendReportDialog } from './reporting';
|
||||||
import { fallbackLng } from './i18n';
|
import { fallbackLng } from './i18n';
|
||||||
import { createSegment, getCleanCutSegments, findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment, getSegmentTags } from './segments';
|
import { findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment, getSegmentTags } from './segments';
|
||||||
import { generateOutSegFileNames as generateOutSegFileNamesRaw, defaultOutSegTemplate } from './util/outputNameTemplate';
|
import { generateOutSegFileNames as generateOutSegFileNamesRaw, defaultOutSegTemplate } from './util/outputNameTemplate';
|
||||||
import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants';
|
import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants';
|
||||||
import BigWaveform from './components/BigWaveform';
|
import BigWaveform from './components/BigWaveform';
|
||||||
|
|
||||||
import isDev from './isDev';
|
import isDev from './isDev';
|
||||||
import { Chapter, ChromiumHTMLVideoElement, CustomTagsByFile, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, FormatTimecode, goToTimecodeDirectArgsSchema, openFilesActionArgsSchema, ParamsByStreamId, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types';
|
import { Chapter, CustomTagsByFile, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, goToTimecodeDirectArgsSchema, openFilesActionArgsSchema, ParamsByStreamId, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, TunerType } from './types';
|
||||||
import { CaptureFormat, KeyboardAction, Html5ifyMode, WaveformMode, ApiActionRequest } from '../../../types';
|
import { CaptureFormat, KeyboardAction, Html5ifyMode, WaveformMode, ApiActionRequest } from '../../../types';
|
||||||
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe';
|
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe';
|
||||||
|
import useLoading from './hooks/useLoading';
|
||||||
|
import useVideo from './hooks/useVideo';
|
||||||
|
import useTimecode from './hooks/useTimecode';
|
||||||
|
import useSegmentsAutoSave from './hooks/useSegmentsAutoSave';
|
||||||
|
import useThumbnails from './hooks/useThumbnails';
|
||||||
|
import useSubtitles from './hooks/useSubtitles';
|
||||||
|
import useStreamsMeta from './hooks/useStreamsMeta';
|
||||||
|
import { bottomStyle, videoStyle } from './styles';
|
||||||
|
|
||||||
const electron = window.require('electron');
|
const electron = window.require('electron');
|
||||||
const { exists } = window.require('fs-extra');
|
const { exists } = window.require('fs-extra');
|
||||||
@ -102,9 +104,6 @@ const { parse: parsePath, join: pathJoin, basename, dirname } = window.require('
|
|||||||
const { focusWindow, hasDisabledNetworking, quitApp, pathToFileURL, setProgressBar, sendOsNotification } = window.require('@electron/remote').require('./index.js');
|
const { focusWindow, hasDisabledNetworking, quitApp, pathToFileURL, setProgressBar, sendOsNotification } = window.require('@electron/remote').require('./index.js');
|
||||||
|
|
||||||
|
|
||||||
const videoStyle: CSSProperties = { width: '100%', height: '100%', objectFit: 'contain' };
|
|
||||||
const bottomStyle: CSSProperties = { background: controlsBackground, transition: darkModeTransition };
|
|
||||||
|
|
||||||
const hevcPlaybackSupportedPromise = doesPlayerSupportHevcPlayback();
|
const hevcPlaybackSupportedPromise = doesPlayerSupportHevcPlayback();
|
||||||
// eslint-disable-next-line unicorn/prefer-top-level-await
|
// eslint-disable-next-line unicorn/prefer-top-level-await
|
||||||
hevcPlaybackSupportedPromise.catch((err) => console.error(err));
|
hevcPlaybackSupportedPromise.catch((err) => console.error(err));
|
||||||
@ -114,16 +113,9 @@ function App() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Per project state
|
// Per project state
|
||||||
const [commandedTime, setCommandedTime] = useState(0);
|
|
||||||
const [ffmpegCommandLog, setFfmpegCommandLog] = useState<FfmpegCommandLog>([]);
|
const [ffmpegCommandLog, setFfmpegCommandLog] = useState<FfmpegCommandLog>([]);
|
||||||
const [previewFilePath, setPreviewFilePath] = useState<string>();
|
const [previewFilePath, setPreviewFilePath] = useState<string>();
|
||||||
const [working, setWorkingState] = useState<{ text: string, abortController?: AbortController | undefined }>();
|
|
||||||
const [usingDummyVideo, setUsingDummyVideo] = useState(false);
|
const [usingDummyVideo, setUsingDummyVideo] = useState(false);
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
const [compatPlayerEventId, setCompatPlayerEventId] = useState(0);
|
|
||||||
const playbackModeRef = useRef<PlaybackMode>();
|
|
||||||
const [playerTime, setPlayerTime] = useState<number>();
|
|
||||||
const [duration, setDuration] = useState<number>();
|
|
||||||
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);
|
||||||
@ -133,14 +125,11 @@ function App() {
|
|||||||
const [paramsByStreamId, setParamsByStreamId] = useState<ParamsByStreamId>(new Map());
|
const [paramsByStreamId, setParamsByStreamId] = useState<ParamsByStreamId>(new Map());
|
||||||
const [detectedFps, setDetectedFps] = useState<number>();
|
const [detectedFps, setDetectedFps] = useState<number>();
|
||||||
const [mainFileMeta, setMainFileMeta] = useState<{ streams: FFprobeStream[], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>();
|
const [mainFileMeta, setMainFileMeta] = useState<{ streams: FFprobeStream[], formatData: FFprobeFormat, chapters: FFprobeChapter[] }>();
|
||||||
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState<Record<string, Record<string, boolean>>>({});
|
|
||||||
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
|
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
|
||||||
const [concatDialogVisible, setConcatDialogVisible] = useState(false);
|
const [concatDialogVisible, setConcatDialogVisible] = useState(false);
|
||||||
const [zoomUnrounded, setZoom] = useState(1);
|
const [zoomUnrounded, setZoom] = useState(1);
|
||||||
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
|
|
||||||
const [shortestFlag, setShortestFlag] = useState(false);
|
const [shortestFlag, setShortestFlag] = useState(false);
|
||||||
const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0);
|
const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0);
|
||||||
const [subtitlesByStreamId, setSubtitlesByStreamId] = useState<Record<string, { url: string, lang?: string }>>({});
|
|
||||||
const [activeVideoStreamIndex, setActiveVideoStreamIndex] = useState<number>();
|
const [activeVideoStreamIndex, setActiveVideoStreamIndex] = useState<number>();
|
||||||
const [activeAudioStreamIndex, setActiveAudioStreamIndex] = useState<number>();
|
const [activeAudioStreamIndex, setActiveAudioStreamIndex] = useState<number>();
|
||||||
const [activeSubtitleStreamIndex, setActiveSubtitleStreamIndex] = useState<number>();
|
const [activeSubtitleStreamIndex, setActiveSubtitleStreamIndex] = useState<number>();
|
||||||
@ -148,8 +137,6 @@ function App() {
|
|||||||
const [exportConfirmVisible, setExportConfirmVisible] = useState(false);
|
const [exportConfirmVisible, setExportConfirmVisible] = useState(false);
|
||||||
const [cacheBuster, setCacheBuster] = useState(0);
|
const [cacheBuster, setCacheBuster] = useState(0);
|
||||||
const [mergedOutFileName, setMergedOutFileName] = useState<string>();
|
const [mergedOutFileName, setMergedOutFileName] = useState<string>();
|
||||||
const [playbackRate, setPlaybackRateState] = useState(1);
|
|
||||||
const [outputPlaybackRate, setOutputPlaybackRateState] = useState(1);
|
|
||||||
|
|
||||||
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
||||||
|
|
||||||
@ -177,35 +164,25 @@ function App() {
|
|||||||
const [batchFiles, setBatchFiles] = useState<{ path: string }[]>([]);
|
const [batchFiles, setBatchFiles] = useState<{ path: string }[]>([]);
|
||||||
const [selectedBatchFiles, setSelectedBatchFiles] = useState<string[]>([]);
|
const [selectedBatchFiles, setSelectedBatchFiles] = useState<string[]>([]);
|
||||||
|
|
||||||
// Store "working" in a ref so we can avoid race conditions
|
|
||||||
const workingRef = useRef(!!working);
|
|
||||||
const setWorking = useCallback((valOrBool?: { text: string, abortController?: AbortController } | true | undefined) => {
|
|
||||||
workingRef.current = !!valOrBool;
|
|
||||||
const val = valOrBool === true ? { text: t('Loading') } : valOrBool;
|
|
||||||
setWorkingState(val ? { text: val.text, abortController: val.abortController } : undefined);
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const handleAbortWorkingClick = useCallback(() => {
|
|
||||||
console.log('User clicked abort');
|
|
||||||
abortFfmpegs(); // todo use abortcontroller for this also
|
|
||||||
working?.abortController?.abort();
|
|
||||||
}, [working?.abortController]);
|
|
||||||
|
|
||||||
useEffect(() => setDocumentTitle({ filePath, working: working?.text, cutProgress }), [cutProgress, filePath, working?.text]);
|
|
||||||
|
|
||||||
useEffect(() => setProgressBar(cutProgress ?? -1), [cutProgress]);
|
|
||||||
|
|
||||||
const zoom = Math.floor(zoomUnrounded);
|
|
||||||
|
|
||||||
const durationSafe = isDurationValid(duration) ? duration : 1;
|
|
||||||
const zoomedDuration = isDurationValid(duration) ? duration / zoom : undefined;
|
|
||||||
|
|
||||||
const allUserSettings = useUserSettingsRoot();
|
const allUserSettings = useUserSettingsRoot();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames,
|
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames,
|
||||||
} = allUserSettings;
|
} = allUserSettings;
|
||||||
|
|
||||||
|
const { working, setWorking, workingRef, abortWorking } = useLoading();
|
||||||
|
const { videoRef, videoContainerRef, playbackRate, setPlaybackRate, outputPlaybackRate, setOutputPlaybackRate, commandedTime, seekAbs, playingRef, getRelevantTime, setPlaying, onSeeked, relevantTime, onSartPlaying, setCommandedTime, setCompatPlayerEventId, compatPlayerEventId, setOutputPlaybackRateState, commandedTimeRef, onStopPlaying, onVideoAbort, duration, setDuration, playerTime, setPlayerTime, playbackModeRef, onDurationChange, playing, play, pause, seekRel } = useVideo({ filePath });
|
||||||
|
const { timecodePlaceholder, formatTimecode, formatTimeAndFrames, parseTimecode, getFrameCount } = useTimecode({ detectedFps, timecodeFormat });
|
||||||
|
const { loadSubtitle, subtitlesByStreamId, setSubtitlesByStreamId } = useSubtitles();
|
||||||
|
|
||||||
|
const durationSafe = isDurationValid(duration) ? duration : 1;
|
||||||
|
const zoom = Math.floor(zoomUnrounded);
|
||||||
|
const zoomedDuration = isDurationValid(duration) ? duration / zoom : undefined;
|
||||||
|
|
||||||
|
useEffect(() => setDocumentTitle({ filePath, working: working?.text, cutProgress }), [cutProgress, filePath, working?.text]);
|
||||||
|
|
||||||
|
useEffect(() => setProgressBar(cutProgress ?? -1), [cutProgress]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ffmpegSetCustomFfPath(customFfPath);
|
ffmpegSetCustomFfPath(customFfPath);
|
||||||
}, [customFfPath]);
|
}, [customFfPath]);
|
||||||
@ -218,22 +195,10 @@ function App() {
|
|||||||
electron.ipcRenderer.send('setLanguage', l);
|
electron.ipcRenderer.send('setLanguage', l);
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
const videoRef = useRef<ChromiumHTMLVideoElement>(null);
|
|
||||||
const videoContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const setPlaybackRate = useCallback((rate: number) => {
|
|
||||||
if (videoRef.current) videoRef.current.playbackRate = rate;
|
|
||||||
setPlaybackRateState(rate);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setOutputPlaybackRate = useCallback((rate: number) => {
|
|
||||||
setOutputPlaybackRateState(rate);
|
|
||||||
if (videoRef.current) videoRef.current.playbackRate = rate;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isFileOpened = !!filePath;
|
const isFileOpened = !!filePath;
|
||||||
|
|
||||||
const onOutputFormatUserChange = useCallback((newFormat) => {
|
const onOutputFormatUserChange = useCallback((newFormat: string) => {
|
||||||
setFileFormat(newFormat);
|
setFileFormat(newFormat);
|
||||||
if (outFormatLocked) {
|
if (outFormatLocked) {
|
||||||
setOutFormatLocked(newFormat === detectedFileFormat ? undefined : newFormat);
|
setOutFormatLocked(newFormat === detectedFileFormat ? undefined : newFormat);
|
||||||
@ -285,19 +250,8 @@ function App() {
|
|||||||
setFfmpegCommandLog((old) => [...old, { command, time: new Date() }]);
|
setFfmpegCommandLog((old) => [...old, { command, time: new Date() }]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setCopyStreamIdsForPath = useCallback<Parameters<typeof StreamsSelector>[0]['setCopyStreamIdsForPath']>((path, cb) => {
|
|
||||||
setCopyStreamIdsByFile((old) => {
|
|
||||||
const oldIds = old[path] || {};
|
|
||||||
return ({ ...old, [path]: cb(oldIds) });
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSegmentsList = useCallback(() => setShowRightBar((v) => !v), []);
|
const toggleSegmentsList = useCallback(() => setShowRightBar((v) => !v), []);
|
||||||
|
|
||||||
const toggleCopyStreamId = useCallback((path: string, index: number) => {
|
|
||||||
setCopyStreamIdsForPath(path, (old) => ({ ...old, [index]: !old[index] }));
|
|
||||||
}, [setCopyStreamIdsForPath]);
|
|
||||||
|
|
||||||
const toggleWaveformMode = useCallback(() => {
|
const toggleWaveformMode = useCallback(() => {
|
||||||
if (waveformMode === 'waveform') {
|
if (waveformMode === 'waveform') {
|
||||||
setWaveformMode('big-waveform');
|
setWaveformMode('big-waveform');
|
||||||
@ -316,62 +270,13 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoRef.current) videoRef.current.volume = playbackVolume;
|
if (videoRef.current) videoRef.current.volume = playbackVolume;
|
||||||
}, [playbackVolume]);
|
}, [playbackVolume, videoRef]);
|
||||||
|
|
||||||
// https://kitchen.vibbio.com/blog/optimizing-html5-video-scrubbing/
|
|
||||||
const seekingRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
||||||
const seekToRef = useRef<number>();
|
|
||||||
|
|
||||||
const smoothSeek = useCallback((seekTo: number) => {
|
|
||||||
if (seekingRef.current) {
|
|
||||||
seekToRef.current = seekTo;
|
|
||||||
} else {
|
|
||||||
videoRef.current!.currentTime = seekTo;
|
|
||||||
// safety precaution:
|
|
||||||
seekingRef.current = setTimeout(() => {
|
|
||||||
seekingRef.current = undefined;
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onSeeked = useCallback<ReactEventHandler<HTMLVideoElement>>(() => {
|
|
||||||
if (seekToRef.current != null) {
|
|
||||||
videoRef.current!.currentTime = seekToRef.current;
|
|
||||||
seekToRef.current = undefined;
|
|
||||||
} else {
|
|
||||||
clearTimeout(seekingRef.current);
|
|
||||||
seekingRef.current = undefined;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const seekAbs = useCallback((val: number | undefined) => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (video == null || val == null || Number.isNaN(val)) return;
|
|
||||||
let outVal = val;
|
|
||||||
if (outVal < 0) outVal = 0;
|
|
||||||
if (outVal > video.duration) outVal = video.duration;
|
|
||||||
|
|
||||||
smoothSeek(outVal);
|
|
||||||
setCommandedTime(outVal);
|
|
||||||
setCompatPlayerEventId((id) => id + 1); // To make sure that we can seek even to the same commanded time that we are already add (e.g. loop current segment)
|
|
||||||
}, [smoothSeek]);
|
|
||||||
|
|
||||||
const commandedTimeRef = useRef(commandedTime);
|
|
||||||
useEffect(() => {
|
|
||||||
commandedTimeRef.current = commandedTime;
|
|
||||||
}, [commandedTime]);
|
|
||||||
|
|
||||||
const mainStreams = useMemo(() => mainFileMeta?.streams ?? [], [mainFileMeta?.streams]);
|
const mainStreams = useMemo(() => mainFileMeta?.streams ?? [], [mainFileMeta?.streams]);
|
||||||
const mainFileFormatData = useMemo(() => mainFileMeta?.formatData, [mainFileMeta?.formatData]);
|
const mainFileFormatData = useMemo(() => mainFileMeta?.formatData, [mainFileMeta?.formatData]);
|
||||||
const mainFileChapters = useMemo(() => mainFileMeta?.chapters, [mainFileMeta?.chapters]);
|
const mainFileChapters = useMemo(() => mainFileMeta?.chapters, [mainFileMeta?.chapters]);
|
||||||
|
|
||||||
const isCopyingStreamId = useCallback((path: string | undefined, streamId: number) => (
|
|
||||||
!!((path != null && copyStreamIdsByFile[path]) || {})[streamId]
|
|
||||||
), [copyStreamIdsByFile]);
|
|
||||||
|
|
||||||
const checkCopyingAnyTrackOfType = useCallback((filter: (s: FFprobeStream) => boolean) => mainStreams.some((stream) => isCopyingStreamId(filePath, stream.index) && filter(stream)), [filePath, isCopyingStreamId, mainStreams]);
|
|
||||||
const copyAnyAudioTrack = useMemo(() => checkCopyingAnyTrackOfType((stream) => stream.codec_type === 'audio'), [checkCopyingAnyTrackOfType]);
|
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => getSubtitleStreams(mainStreams), [mainStreams]);
|
const subtitleStreams = useMemo(() => getSubtitleStreams(mainStreams), [mainStreams]);
|
||||||
const videoStreams = useMemo(() => getRealVideoStreams(mainStreams), [mainStreams]);
|
const videoStreams = useMemo(() => getRealVideoStreams(mainStreams), [mainStreams]);
|
||||||
const audioStreams = useMemo(() => getAudioStreams(mainStreams), [mainStreams]);
|
const audioStreams = useMemo(() => getAudioStreams(mainStreams), [mainStreams]);
|
||||||
@ -409,12 +314,6 @@ function App() {
|
|||||||
});
|
});
|
||||||
}, [comfortZoom]);
|
}, [comfortZoom]);
|
||||||
|
|
||||||
const playingRef = useRef(false);
|
|
||||||
|
|
||||||
// Relevant time is the player's playback position if we're currently playing - if not, it's the user's commanded time.
|
|
||||||
const relevantTime = useMemo(() => (playing ? playerTime : commandedTime) || 0, [commandedTime, playerTime, playing]);
|
|
||||||
// The reason why we also have a getter is because it can be used when we need to get the time, but don't want to re-render for every time update (which can be heavy!)
|
|
||||||
const getRelevantTime = useCallback(() => (playingRef.current ? videoRef.current!.currentTime : commandedTimeRef.current) || 0, []);
|
|
||||||
|
|
||||||
const maxLabelLength = safeOutputFileName ? 100 : 500;
|
const maxLabelLength = safeOutputFileName ? 100 : 500;
|
||||||
|
|
||||||
@ -424,47 +323,13 @@ function App() {
|
|||||||
return false;
|
return false;
|
||||||
}, [isFileOpened]);
|
}, [isFileOpened]);
|
||||||
|
|
||||||
const getFrameCount = useCallback((sec: number) => getFrameCountRaw(detectedFps, sec), [detectedFps]);
|
|
||||||
const frameCountToDuration = useCallback((frames: number) => getFrameDuration(detectedFps) * frames, [detectedFps]);
|
|
||||||
|
|
||||||
const formatTimecode = useCallback<FormatTimecode>(({ seconds, shorten, fileNameFriendly }) => {
|
|
||||||
if (timecodeFormat === 'frameCount') {
|
|
||||||
const frameCount = getFrameCount(seconds);
|
|
||||||
return frameCount != null ? String(frameCount) : '';
|
|
||||||
}
|
|
||||||
if (timecodeFormat === 'timecodeWithFramesFraction') {
|
|
||||||
return formatDuration({ seconds, shorten, fileNameFriendly, fps: detectedFps });
|
|
||||||
}
|
|
||||||
return formatDuration({ seconds, shorten, fileNameFriendly });
|
|
||||||
}, [detectedFps, timecodeFormat, getFrameCount]);
|
|
||||||
|
|
||||||
const timecodePlaceholder = useMemo(() => formatTimecode({ seconds: 0, shorten: false }), [formatTimecode]);
|
|
||||||
|
|
||||||
const parseTimecode = useCallback<ParseTimecode>((val: string) => {
|
|
||||||
if (timecodeFormat === 'frameCount') {
|
|
||||||
const parsed = parseInt(val, 10);
|
|
||||||
return frameCountToDuration(parsed);
|
|
||||||
}
|
|
||||||
if (timecodeFormat === 'timecodeWithFramesFraction') {
|
|
||||||
return parseDuration(val, detectedFps);
|
|
||||||
}
|
|
||||||
return parseDuration(val);
|
|
||||||
}, [detectedFps, frameCountToDuration, timecodeFormat]);
|
|
||||||
|
|
||||||
const formatTimeAndFrames = useCallback((seconds: number) => {
|
|
||||||
const frameCount = getFrameCount(seconds);
|
|
||||||
|
|
||||||
const timeStr = timecodeFormat === 'timecodeWithFramesFraction'
|
|
||||||
? formatDuration({ seconds, fps: detectedFps })
|
|
||||||
: formatDuration({ seconds });
|
|
||||||
|
|
||||||
return `${timeStr} (${frameCount ?? '0'})`;
|
|
||||||
}, [detectedFps, timecodeFormat, getFrameCount]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, focusSegmentAtCursor, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex,
|
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, focusSegmentAtCursor, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex,
|
||||||
} = useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode });
|
} = useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode });
|
||||||
|
|
||||||
|
const { getEdlFilePath, getEdlFilePathOld, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments });
|
||||||
|
|
||||||
|
const { nonCopiedExtraStreams, exportExtraStreams, mainCopiedThumbnailStreams, numStreamsToCopy, toggleStripAudio, toggleStripThumbnail, copyAnyAudioTrack, copyStreamIdsByFile, setCopyStreamIdsByFile, copyFileStreams, mainCopiedStreams, setCopyStreamIdsForPath, toggleCopyStreamId, isCopyingStreamId } = useStreamsMeta({ mainStreams, filePath, autoExportExtraStreams });
|
||||||
|
|
||||||
const segmentAtCursor = useMemo(() => {
|
const segmentAtCursor = useMemo(() => {
|
||||||
const segmentsAtCursorIndexes = findSegmentsAtCursor(apparentCutSegments, commandedTime);
|
const segmentsAtCursorIndexes = findSegmentsAtCursor(apparentCutSegments, commandedTime);
|
||||||
@ -479,12 +344,6 @@ function App() {
|
|||||||
segmentAtCursorRef.current = segmentAtCursor;
|
segmentAtCursorRef.current = segmentAtCursor;
|
||||||
}, [segmentAtCursor]);
|
}, [segmentAtCursor]);
|
||||||
|
|
||||||
const userSeekAbs = useCallback((val: number) => seekAbs(val), [seekAbs]);
|
|
||||||
|
|
||||||
const seekRel = useCallback((val: number) => {
|
|
||||||
userSeekAbs(videoRef.current!.currentTime + val);
|
|
||||||
}, [userSeekAbs]);
|
|
||||||
|
|
||||||
const seekRelPercent = useCallback((val: number) => {
|
const seekRelPercent = useCallback((val: number) => {
|
||||||
if (!isDurationValid(zoomedDuration)) return;
|
if (!isDurationValid(zoomedDuration)) return;
|
||||||
seekRel(val * zoomedDuration);
|
seekRel(val * zoomedDuration);
|
||||||
@ -500,15 +359,15 @@ function App() {
|
|||||||
const currentTimeNearestFrameNumber = getFrameCountRaw(fps, videoRef.current!.currentTime);
|
const currentTimeNearestFrameNumber = getFrameCountRaw(fps, videoRef.current!.currentTime);
|
||||||
invariant(currentTimeNearestFrameNumber != null);
|
invariant(currentTimeNearestFrameNumber != null);
|
||||||
const nextFrame = currentTimeNearestFrameNumber + direction;
|
const nextFrame = currentTimeNearestFrameNumber + direction;
|
||||||
userSeekAbs(nextFrame / fps);
|
seekAbs(nextFrame / fps);
|
||||||
}, [detectedFps, userSeekAbs]);
|
}, [detectedFps, seekAbs, videoRef]);
|
||||||
|
|
||||||
const jumpSegStart = useCallback((index: number) => userSeekAbs(apparentCutSegments[index]!.start), [apparentCutSegments, userSeekAbs]);
|
const jumpSegStart = useCallback((index: number) => seekAbs(apparentCutSegments[index]!.start), [apparentCutSegments, seekAbs]);
|
||||||
const jumpSegEnd = useCallback((index: number) => userSeekAbs(apparentCutSegments[index]!.end), [apparentCutSegments, userSeekAbs]);
|
const jumpSegEnd = useCallback((index: number) => seekAbs(apparentCutSegments[index]!.end), [apparentCutSegments, seekAbs]);
|
||||||
const jumpCutStart = useCallback(() => jumpSegStart(currentSegIndexSafe), [currentSegIndexSafe, jumpSegStart]);
|
const jumpCutStart = useCallback(() => jumpSegStart(currentSegIndexSafe), [currentSegIndexSafe, jumpSegStart]);
|
||||||
const jumpCutEnd = useCallback(() => jumpSegEnd(currentSegIndexSafe), [currentSegIndexSafe, jumpSegEnd]);
|
const jumpCutEnd = useCallback(() => jumpSegEnd(currentSegIndexSafe), [currentSegIndexSafe, jumpSegEnd]);
|
||||||
const jumpTimelineStart = useCallback(() => userSeekAbs(0), [userSeekAbs]);
|
const jumpTimelineStart = useCallback(() => seekAbs(0), [seekAbs]);
|
||||||
const jumpTimelineEnd = useCallback(() => userSeekAbs(durationSafe), [durationSafe, userSeekAbs]);
|
const jumpTimelineEnd = useCallback(() => seekAbs(durationSafe), [durationSafe, seekAbs]);
|
||||||
|
|
||||||
const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ formatTimecode, treatOutputFileModifiedTimeAsStart });
|
const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ formatTimecode, treatOutputFileModifiedTimeAsStart });
|
||||||
|
|
||||||
@ -530,72 +389,6 @@ function App() {
|
|||||||
return uri;
|
return uri;
|
||||||
}, [cacheBuster, effectiveFilePath]);
|
}, [cacheBuster, effectiveFilePath]);
|
||||||
|
|
||||||
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 }), []);
|
|
||||||
// Old versions of LosslessCut used CSV files and stored them always in customOutDir:
|
|
||||||
const getEdlFilePathOld = useCallback((fp: string | undefined, cod?: string | undefined) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: oldProjectSuffix }), []);
|
|
||||||
const getProjectFileSavePath = useCallback((storeProjectInWorkingDirIn: boolean) => getEdlFilePath(filePath, storeProjectInWorkingDirIn ? customOutDir : undefined), [getEdlFilePath, filePath, customOutDir]);
|
|
||||||
const projectFileSavePath = useMemo(() => getProjectFileSavePath(storeProjectInWorkingDir), [getProjectFileSavePath, storeProjectInWorkingDir]);
|
|
||||||
|
|
||||||
const currentSaveOperation = useMemo(() => {
|
|
||||||
if (!projectFileSavePath) return undefined;
|
|
||||||
return { cutSegments, projectFileSavePath, filePath };
|
|
||||||
}, [cutSegments, filePath, projectFileSavePath]);
|
|
||||||
|
|
||||||
const [debouncedSaveOperation] = useDebounce(currentSaveOperation, isDev ? 2000 : 500);
|
|
||||||
|
|
||||||
const lastSaveOperation = useRef<typeof debouncedSaveOperation>();
|
|
||||||
useEffect(() => {
|
|
||||||
async function save() {
|
|
||||||
// NOTE: Could lose a save if user closes too fast, but not a big issue I think
|
|
||||||
if (!autoSaveProjectFile || !debouncedSaveOperation) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Initial state? Don't save (same as createInitialCutSegments but without counting)
|
|
||||||
if (isEqual(getCleanCutSegments(debouncedSaveOperation.cutSegments), getCleanCutSegments([createSegment()]))) return;
|
|
||||||
|
|
||||||
if (lastSaveOperation.current && lastSaveOperation.current.projectFileSavePath === debouncedSaveOperation.projectFileSavePath && isEqual(getCleanCutSegments(lastSaveOperation.current.cutSegments), getCleanCutSegments(debouncedSaveOperation.cutSegments))) {
|
|
||||||
console.log('Segments unchanged, skipping save');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveLlcProject({ savePath: debouncedSaveOperation.projectFileSavePath, filePath: debouncedSaveOperation.filePath, cutSegments: debouncedSaveOperation.cutSegments });
|
|
||||||
lastSaveOperation.current = debouncedSaveOperation;
|
|
||||||
} catch (err) {
|
|
||||||
errorToast(i18n.t('Unable to save project file'));
|
|
||||||
console.error('Failed to save project file', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
save();
|
|
||||||
}, [debouncedSaveOperation, autoSaveProjectFile]);
|
|
||||||
|
|
||||||
function onPlayingChange(val) {
|
|
||||||
playingRef.current = val;
|
|
||||||
setPlaying(val);
|
|
||||||
if (!val) {
|
|
||||||
setCommandedTime(videoRef.current!.currentTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onStopPlaying = useCallback(() => {
|
|
||||||
onPlayingChange(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onVideoAbort = useCallback(() => {
|
|
||||||
setPlaying(false); // we want to preserve current time https://github.com/mifi/lossless-cut/issues/1674#issuecomment-1658937716
|
|
||||||
playbackModeRef.current = undefined;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onSartPlaying = useCallback(() => onPlayingChange(true), []);
|
|
||||||
const onDurationChange = useCallback((e) => {
|
|
||||||
// Some files report duration infinity first, then proper duration later
|
|
||||||
// Sometimes after seeking to end of file, duration might change
|
|
||||||
const { duration: durationNew } = e.target;
|
|
||||||
console.log('onDurationChange', durationNew);
|
|
||||||
if (isDurationValid(durationNew)) setDuration(durationNew);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const increaseRotation = useCallback(() => {
|
const increaseRotation = useCallback(() => {
|
||||||
setRotation((r) => (r + 90) % 450);
|
setRotation((r) => (r + 90) % 450);
|
||||||
@ -699,47 +492,27 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
setWorking({ text: i18n.t('Loading subtitle') });
|
setWorking({ text: i18n.t('Loading subtitle') });
|
||||||
invariant(filePath != null);
|
invariant(filePath != null);
|
||||||
const url = await extractSubtitleTrackVtt(filePath, index);
|
await loadSubtitle({ filePath, index, subtitleStream });
|
||||||
setSubtitlesByStreamId((old) => ({ ...old, [index]: { url, lang: subtitleStream.tags && subtitleStream.tags.language } }));
|
|
||||||
setActiveSubtitleStreamIndex(index);
|
setActiveSubtitleStreamIndex(index);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(`Failed to extract subtitles for stream ${index}`, err instanceof Error && err.message);
|
handleError(`Failed to extract subtitles for stream ${index}`, err instanceof Error && err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
}
|
}
|
||||||
}, [setWorking, subtitleStreams, subtitlesByStreamId, filePath]);
|
}, [subtitlesByStreamId, subtitleStreams, workingRef, setWorking, filePath, loadSubtitle]);
|
||||||
|
|
||||||
const onActiveVideoStreamChange = useCallback((index?: number) => {
|
const onActiveVideoStreamChange = useCallback((index?: number) => {
|
||||||
invariant(videoRef.current);
|
invariant(videoRef.current);
|
||||||
setHideMediaSourcePlayer(index == null || getVideoTrackForStreamIndex(videoRef.current, index) != null);
|
setHideMediaSourcePlayer(index == null || getVideoTrackForStreamIndex(videoRef.current, index) != null);
|
||||||
enableVideoTrack(videoRef.current, index);
|
enableVideoTrack(videoRef.current, index);
|
||||||
setActiveVideoStreamIndex(index);
|
setActiveVideoStreamIndex(index);
|
||||||
}, []);
|
}, [videoRef]);
|
||||||
const onActiveAudioStreamChange = useCallback((index?: number) => {
|
const onActiveAudioStreamChange = useCallback((index?: number) => {
|
||||||
invariant(videoRef.current);
|
invariant(videoRef.current);
|
||||||
setHideMediaSourcePlayer(index == null || getAudioTrackForStreamIndex(videoRef.current, index) != null);
|
setHideMediaSourcePlayer(index == null || getAudioTrackForStreamIndex(videoRef.current, index) != null);
|
||||||
enableAudioTrack(videoRef.current, index);
|
enableAudioTrack(videoRef.current, index);
|
||||||
setActiveAudioStreamIndex(index);
|
setActiveAudioStreamIndex(index);
|
||||||
}, []);
|
}, [videoRef]);
|
||||||
|
|
||||||
const mainCopiedStreams = useMemo(() => mainStreams.filter((stream) => isCopyingStreamId(filePath, stream.index)), [filePath, isCopyingStreamId, mainStreams]);
|
|
||||||
const mainCopiedThumbnailStreams = useMemo(() => mainCopiedStreams.filter((stream) => isStreamThumbnail(stream)), [mainCopiedStreams]);
|
|
||||||
|
|
||||||
// Streams that are not copy enabled by default
|
|
||||||
const extraStreams = useMemo(() => mainStreams.filter((stream) => !shouldCopyStreamByDefault(stream)), [mainStreams]);
|
|
||||||
|
|
||||||
// Extra streams that the user has not selected for copy
|
|
||||||
const nonCopiedExtraStreams = useMemo(() => extraStreams.filter((stream) => !isCopyingStreamId(filePath, stream.index)), [extraStreams, filePath, isCopyingStreamId]);
|
|
||||||
|
|
||||||
const exportExtraStreams = autoExportExtraStreams && nonCopiedExtraStreams.length > 0;
|
|
||||||
|
|
||||||
const copyFileStreams = useMemo(() => Object.entries(copyStreamIdsByFile).map(([path, streamIdsMap]) => ({
|
|
||||||
path,
|
|
||||||
streamIds: Object.entries(streamIdsMap).filter(([, shouldCopy]) => shouldCopy).map(([streamIdStr]) => parseInt(streamIdStr, 10)),
|
|
||||||
})), [copyStreamIdsByFile]);
|
|
||||||
|
|
||||||
// total number of streams to copy for ALL files
|
|
||||||
const numStreamsToCopy = useMemo(() => copyFileStreams.reduce((acc, { streamIds }) => acc + streamIds.length, 0), [copyFileStreams]);
|
|
||||||
|
|
||||||
const allFilesMeta = useMemo(() => ({
|
const allFilesMeta = useMemo(() => ({
|
||||||
...externalFilesMeta,
|
...externalFilesMeta,
|
||||||
@ -747,30 +520,7 @@ function App() {
|
|||||||
}), [externalFilesMeta, filePath, mainFileMeta]);
|
}), [externalFilesMeta, filePath, mainFileMeta]);
|
||||||
|
|
||||||
// total number of streams for ALL files
|
// total number of streams for ALL files
|
||||||
const numStreamsTotal = flatMap(Object.values(allFilesMeta), ({ streams }) => streams).length;
|
const numStreamsTotal = Object.values(allFilesMeta).flatMap(({ streams }) => streams).length;
|
||||||
|
|
||||||
const toggleStripStream = useCallback((filter) => {
|
|
||||||
const copyingAnyTrackOfType = checkCopyingAnyTrackOfType(filter);
|
|
||||||
invariant(filePath != null);
|
|
||||||
setCopyStreamIdsForPath(filePath, (old) => {
|
|
||||||
const newCopyStreamIds = { ...old };
|
|
||||||
mainStreams.forEach((stream) => {
|
|
||||||
if (filter(stream)) newCopyStreamIds[stream.index] = !copyingAnyTrackOfType;
|
|
||||||
});
|
|
||||||
return newCopyStreamIds;
|
|
||||||
});
|
|
||||||
}, [checkCopyingAnyTrackOfType, filePath, mainStreams, setCopyStreamIdsForPath]);
|
|
||||||
|
|
||||||
const toggleStripAudio = useCallback(() => toggleStripStream((stream) => stream.codec_type === 'audio'), [toggleStripStream]);
|
|
||||||
const toggleStripThumbnail = useCallback(() => toggleStripStream(isStreamThumbnail), [toggleStripStream]);
|
|
||||||
|
|
||||||
const thumnailsRef = useRef<Thumbnail[]>([]);
|
|
||||||
const thumnailsRenderingPromiseRef = useRef<Promise<void>>();
|
|
||||||
|
|
||||||
function addThumbnail(thumbnail) {
|
|
||||||
// console.log('Rendered thumbnail', thumbnail.url);
|
|
||||||
setThumbnails((v) => [...v, thumbnail]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAudio = !!activeAudioStream;
|
const hasAudio = !!activeAudioStream;
|
||||||
const hasVideo = !!activeVideoStream;
|
const hasVideo = !!activeVideoStream;
|
||||||
@ -779,43 +529,7 @@ function App() {
|
|||||||
const bigWaveformEnabled = waveformEnabled && waveformMode === 'big-waveform';
|
const bigWaveformEnabled = waveformEnabled && waveformMode === 'big-waveform';
|
||||||
const showThumbnails = thumbnailsEnabled && hasVideo;
|
const showThumbnails = thumbnailsEnabled && hasVideo;
|
||||||
|
|
||||||
const [, cancelRenderThumbnails] = useDebounceOld(() => {
|
const { cancelRenderThumbnails, thumbnailsSorted, setThumbnails } = useThumbnails({ filePath, zoomedDuration, zoomWindowStartTime, showThumbnails });
|
||||||
async function renderThumbnails() {
|
|
||||||
if (!showThumbnails || thumnailsRenderingPromiseRef.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setThumbnails([]);
|
|
||||||
invariant(filePath != null);
|
|
||||||
invariant(zoomedDuration != null);
|
|
||||||
const promise = ffmpegRenderThumbnails({ filePath, from: zoomWindowStartTime, duration: zoomedDuration, onThumbnail: addThumbnail });
|
|
||||||
thumnailsRenderingPromiseRef.current = promise;
|
|
||||||
await promise;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to render thumbnail', err);
|
|
||||||
} finally {
|
|
||||||
thumnailsRenderingPromiseRef.current = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDurationValid(zoomedDuration)) renderThumbnails();
|
|
||||||
}, 500, [zoomedDuration, filePath, zoomWindowStartTime, showThumbnails]);
|
|
||||||
|
|
||||||
// Cleanup removed thumbnails
|
|
||||||
useEffect(() => {
|
|
||||||
thumnailsRef.current.forEach((thumbnail) => {
|
|
||||||
if (!thumbnails.some((nextThumbnail) => nextThumbnail.url === thumbnail.url)) URL.revokeObjectURL(thumbnail.url);
|
|
||||||
});
|
|
||||||
thumnailsRef.current = thumbnails;
|
|
||||||
}, [thumbnails]);
|
|
||||||
|
|
||||||
// Cleanup removed subtitles
|
|
||||||
const subtitlesByStreamIdRef = useRef({});
|
|
||||||
useEffect(() => {
|
|
||||||
Object.values(thumnailsRef.current).forEach(({ url }) => {
|
|
||||||
if (!Object.values(subtitlesByStreamId).some((existingThumbnail) => existingThumbnail.url === url)) URL.revokeObjectURL(url);
|
|
||||||
});
|
|
||||||
subtitlesByStreamIdRef.current = subtitlesByStreamId;
|
|
||||||
}, [subtitlesByStreamId]);
|
|
||||||
|
|
||||||
const shouldShowKeyframes = keyframesEnabled && hasVideo && calcShouldShowKeyframes(zoomedDuration);
|
const shouldShowKeyframes = keyframesEnabled && hasVideo && calcShouldShowKeyframes(zoomedDuration);
|
||||||
const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration);
|
const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration);
|
||||||
@ -877,7 +591,7 @@ function App() {
|
|||||||
setOutputPlaybackRateState(1);
|
setOutputPlaybackRateState(1);
|
||||||
|
|
||||||
cancelRenderThumbnails();
|
cancelRenderThumbnails();
|
||||||
}, [setPlaybackRate, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setDeselectedSegmentIds, resetMergedOutFileName, cancelRenderThumbnails]);
|
}, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setDuration, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, resetMergedOutFileName, setOutputPlaybackRateState, cancelRenderThumbnails]);
|
||||||
|
|
||||||
|
|
||||||
const showUnsupportedFileMessage = useCallback(() => {
|
const showUnsupportedFileMessage = useCallback(() => {
|
||||||
@ -970,7 +684,7 @@ function App() {
|
|||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
setCutProgress(undefined);
|
setCutProgress(undefined);
|
||||||
}
|
}
|
||||||
}, [batchFiles, customOutDir, ensureWritableOutDir, html5ify, setWorking]);
|
}, [batchFiles, customOutDir, ensureWritableOutDir, html5ify, setWorking, workingRef]);
|
||||||
|
|
||||||
const getConvertToSupportedFormat = useCallback((fallback) => rememberConvertToSupportedFormat || fallback, [rememberConvertToSupportedFormat]);
|
const getConvertToSupportedFormat = useCallback((fallback) => rememberConvertToSupportedFormat || fallback, [rememberConvertToSupportedFormat]);
|
||||||
|
|
||||||
@ -980,34 +694,9 @@ function App() {
|
|||||||
await html5ifyAndLoad(cod, fp, getConvertToSupportedFormat(speed), hv, ha);
|
await html5ifyAndLoad(cod, fp, getConvertToSupportedFormat(speed), hv, ha);
|
||||||
}, [enableAutoHtml5ify, setWorking, html5ifyAndLoad, getConvertToSupportedFormat]);
|
}, [enableAutoHtml5ify, setWorking, html5ifyAndLoad, getConvertToSupportedFormat]);
|
||||||
|
|
||||||
const showPlaybackFailedMessage = () => errorToast(i18n.t('Unable to playback this file. Try to convert to supported format from the menu'));
|
|
||||||
|
|
||||||
const getNewJumpIndex = (oldIndex: number, direction: -1 | 1) => Math.max(oldIndex + direction, 0);
|
const getNewJumpIndex = (oldIndex: number, direction: -1 | 1) => Math.max(oldIndex + direction, 0);
|
||||||
const jumpSeg = useCallback((direction: -1 | 1) => setCurrentSegIndex((old) => Math.min(getNewJumpIndex(old, direction), cutSegments.length - 1)), [cutSegments, setCurrentSegIndex]);
|
const jumpSeg = useCallback((direction: -1 | 1) => setCurrentSegIndex((old) => Math.min(getNewJumpIndex(old, direction), cutSegments.length - 1)), [cutSegments, setCurrentSegIndex]);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
if (!filePath || !playingRef.current) return;
|
|
||||||
videoRef.current!.pause();
|
|
||||||
}, [filePath]);
|
|
||||||
|
|
||||||
const play = useCallback((resetPlaybackRate?: boolean) => {
|
|
||||||
if (!filePath || playingRef.current) return;
|
|
||||||
|
|
||||||
const video = videoRef.current;
|
|
||||||
|
|
||||||
// This was added to re-sync time if file gets reloaded #1674 - but I had to remove this because it broke loop-selected-segments https://github.com/mifi/lossless-cut/discussions/1785#discussioncomment-7852134
|
|
||||||
// if (Math.abs(commandedTimeRef.current - video.currentTime) > 1) video.currentTime = commandedTimeRef.current;
|
|
||||||
|
|
||||||
if (resetPlaybackRate) setPlaybackRate(outputPlaybackRate);
|
|
||||||
video?.play().catch((err) => {
|
|
||||||
if (err instanceof Error && err.name === 'AbortError' && 'code' in err && err.code === 20) { // Probably "DOMException: The play() request was interrupted by a call to pause()."
|
|
||||||
console.error(err);
|
|
||||||
} else {
|
|
||||||
showPlaybackFailedMessage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [filePath, outputPlaybackRate, setPlaybackRate]);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(({ resetPlaybackRate, requestPlaybackMode }: { resetPlaybackRate?: boolean, requestPlaybackMode?: PlaybackMode } | undefined = {}) => {
|
const togglePlay = useCallback(({ resetPlaybackRate, requestPlaybackMode }: { resetPlaybackRate?: boolean, requestPlaybackMode?: PlaybackMode } | undefined = {}) => {
|
||||||
playbackModeRef.current = requestPlaybackMode;
|
playbackModeRef.current = requestPlaybackMode;
|
||||||
|
|
||||||
@ -1032,7 +721,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
play(resetPlaybackRate);
|
play(resetPlaybackRate);
|
||||||
}, [play, pause, selectedSegments, apparentCutSegments, setCurrentSegIndex, seekAbs, currentApparentCutSeg.start]);
|
}, [playbackModeRef, playingRef, play, pause, selectedSegments, commandedTimeRef, apparentCutSegments, setCurrentSegIndex, seekAbs, currentApparentCutSeg.start]);
|
||||||
|
|
||||||
const onTimeUpdate = useCallback<ReactEventHandler<HTMLVideoElement>>((e) => {
|
const onTimeUpdate = useCallback<ReactEventHandler<HTMLVideoElement>>((e) => {
|
||||||
const { currentTime } = e.currentTarget;
|
const { currentTime } = e.currentTarget;
|
||||||
@ -1062,7 +751,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [getApparentCutSegmentById, pause, playerTime, seekAbs, selectedSegments]);
|
}, [getApparentCutSegmentById, pause, playbackModeRef, playerTime, seekAbs, selectedSegments, setPlayerTime]);
|
||||||
|
|
||||||
const closeFileWithConfirm = useCallback(() => {
|
const closeFileWithConfirm = useCallback(() => {
|
||||||
if (!isFileOpened || workingRef.current) return;
|
if (!isFileOpened || workingRef.current) return;
|
||||||
@ -1071,7 +760,7 @@ function App() {
|
|||||||
if (askBeforeClose && !window.confirm(i18n.t('Are you sure you want to close the current file?'))) return;
|
if (askBeforeClose && !window.confirm(i18n.t('Are you sure you want to close the current file?'))) return;
|
||||||
|
|
||||||
resetState();
|
resetState();
|
||||||
}, [askBeforeClose, resetState, isFileOpened]);
|
}, [isFileOpened, workingRef, askBeforeClose, resetState]);
|
||||||
|
|
||||||
const closeBatch = useCallback(() => {
|
const closeBatch = useCallback(() => {
|
||||||
// eslint-disable-next-line no-alert
|
// eslint-disable-next-line no-alert
|
||||||
@ -1137,7 +826,7 @@ function App() {
|
|||||||
}, [fileFormat, openSendConcatReportDialogWithState]);
|
}, [fileFormat, openSendConcatReportDialogWithState]);
|
||||||
|
|
||||||
const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: outFormat, outFileName, clearBatchFilesAfterConcat }: {
|
const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: outFormat, outFileName, clearBatchFilesAfterConcat }: {
|
||||||
paths: string[], includeAllStreams: boolean, streams, fileFormat: string, outFileName: string, clearBatchFilesAfterConcat: boolean,
|
paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], fileFormat: string, outFileName: string, clearBatchFilesAfterConcat: boolean,
|
||||||
}) => {
|
}) => {
|
||||||
if (workingRef.current) return;
|
if (workingRef.current) return;
|
||||||
try {
|
try {
|
||||||
@ -1153,7 +842,7 @@ function App() {
|
|||||||
|
|
||||||
const outPath = getOutPath({ customOutDir: newCustomOutDir, filePath: firstPath, fileName: outFileName });
|
const outPath = getOutPath({ customOutDir: newCustomOutDir, filePath: firstPath, fileName: outFileName });
|
||||||
|
|
||||||
let chaptersFromSegments;
|
let chaptersFromSegments: Awaited<ReturnType<typeof createChaptersFromSegments>>;
|
||||||
if (segmentsToChapters) {
|
if (segmentsToChapters) {
|
||||||
const chapterNames = paths.map((path) => parsePath(path).name);
|
const chapterNames = paths.map((path) => parsePath(path).name);
|
||||||
chaptersFromSegments = await createChaptersFromSegments({ segmentPaths: paths, chapterNames });
|
chaptersFromSegments = await createChaptersFromSegments({ segmentPaths: paths, chapterNames });
|
||||||
@ -1212,9 +901,9 @@ function App() {
|
|||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
setCutProgress(undefined);
|
setCutProgress(undefined);
|
||||||
}
|
}
|
||||||
}, [setWorking, ensureWritableOutDir, customOutDir, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, hideAllNotifications, showOsNotification, handleConcatFailed]);
|
}, [workingRef, setWorking, ensureWritableOutDir, customOutDir, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, hideAllNotifications, showOsNotification, handleConcatFailed]);
|
||||||
|
|
||||||
const cleanupFiles = useCallback(async (cleanupChoices2) => {
|
const cleanupFiles = useCallback(async (cleanupChoices2: CleanupChoicesType) => {
|
||||||
// Store paths before we reset state
|
// Store paths before we reset state
|
||||||
const savedPaths = { previewFilePath, sourceFilePath: filePath, projectFilePath: projectFileSavePath };
|
const savedPaths = { previewFilePath, sourceFilePath: filePath, projectFilePath: projectFileSavePath };
|
||||||
|
|
||||||
@ -1250,7 +939,7 @@ function App() {
|
|||||||
}, [cleanupChoices, setCleanupChoices]);
|
}, [cleanupChoices, setCleanupChoices]);
|
||||||
|
|
||||||
const cleanupFilesWithDialog = useCallback(async () => {
|
const cleanupFilesWithDialog = useCallback(async () => {
|
||||||
let response = cleanupChoices;
|
let response: CleanupChoicesType | undefined = cleanupChoices;
|
||||||
if (cleanupChoices.askForCleanup) {
|
if (cleanupChoices.askForCleanup) {
|
||||||
response = await askForCleanupChoices();
|
response = await askForCleanupChoices();
|
||||||
console.log('trashResponse', response);
|
console.log('trashResponse', response);
|
||||||
@ -1269,7 +958,7 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
}
|
}
|
||||||
}, [cleanupFilesWithDialog, isFileOpened, setWorking]);
|
}, [cleanupFilesWithDialog, isFileOpened, setWorking, workingRef]);
|
||||||
|
|
||||||
const generateOutSegFileNames = useCallback(async ({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
|
const generateOutSegFileNames = useCallback(async ({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
|
||||||
if (fileFormat == null || outputDir == null || filePath == null) throw new Error();
|
if (fileFormat == null || outputDir == null || filePath == null) throw new Error();
|
||||||
@ -1440,7 +1129,7 @@ function App() {
|
|||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
setCutProgress(undefined);
|
setCutProgress(undefined);
|
||||||
}
|
}
|
||||||
}, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, mergedOutFilePath, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, resetMergedOutFileName, selectedSegmentsOrInverse, segmentsToChapters, invertCutSegments, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, showOsNotification, handleExportFailed]);
|
}, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, mergedOutFilePath, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, resetMergedOutFileName, selectedSegmentsOrInverse, segmentsToChapters, invertCutSegments, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, showOsNotification, handleExportFailed]);
|
||||||
|
|
||||||
const onExportPress = useCallback(async () => {
|
const onExportPress = useCallback(async () => {
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
@ -1470,7 +1159,7 @@ function App() {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
errorToast(i18n.t('Failed to capture frame'));
|
errorToast(i18n.t('Failed to capture frame'));
|
||||||
}
|
}
|
||||||
}, [filePath, getRelevantTime, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, hideAllNotifications]);
|
}, [filePath, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, hideAllNotifications]);
|
||||||
|
|
||||||
const extractSegmentFramesAsImages = useCallback(async (segIds: string[]) => {
|
const extractSegmentFramesAsImages = useCallback(async (segIds: string[]) => {
|
||||||
if (!filePath || detectedFps == null || workingRef.current) return;
|
if (!filePath || detectedFps == null || workingRef.current) return;
|
||||||
@ -1512,7 +1201,7 @@ function App() {
|
|||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
setCutProgress(undefined);
|
setCutProgress(undefined);
|
||||||
}
|
}
|
||||||
}, [apparentCutSegments, captureFormat, captureFrameFileNameFormat, captureFrameQuality, captureFramesRange, customOutDir, detectedFps, filePath, getFrameCount, hideAllNotifications, outputDir, setWorking, showOsNotification]);
|
}, [apparentCutSegments, captureFormat, captureFrameFileNameFormat, captureFrameQuality, captureFramesRange, customOutDir, detectedFps, filePath, getFrameCount, hideAllNotifications, outputDir, setWorking, showOsNotification, workingRef]);
|
||||||
|
|
||||||
const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentFramesAsImages([currentCutSeg?.segId]), [currentCutSeg?.segId, extractSegmentFramesAsImages]);
|
const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentFramesAsImages([currentCutSeg?.segId]), [currentCutSeg?.segId, extractSegmentFramesAsImages]);
|
||||||
const extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentFramesAsImages(selectedSegments.map((seg) => seg.segId)), [extractSegmentFramesAsImages, selectedSegments]);
|
const extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentFramesAsImages(selectedSegments.map((seg) => seg.segId)), [extractSegmentFramesAsImages, selectedSegments]);
|
||||||
@ -1530,7 +1219,7 @@ function App() {
|
|||||||
const newRate = adjustRate(video!.playbackRate, dir, rateMultiplier);
|
const newRate = adjustRate(video!.playbackRate, dir, rateMultiplier);
|
||||||
setPlaybackRate(newRate);
|
setPlaybackRate(newRate);
|
||||||
}
|
}
|
||||||
}, [compatPlayerEnabled, setPlaybackRate]);
|
}, [compatPlayerEnabled, playingRef, setPlaybackRate, videoRef]);
|
||||||
|
|
||||||
const loadEdlFile = useCallback(async ({ path, type, append }: { path: string, type: EdlFileType, append?: boolean }) => {
|
const loadEdlFile = useCallback(async ({ path, type, append }: { path: string, type: EdlFileType, append?: boolean }) => {
|
||||||
console.log('Loading EDL file', type, path, append);
|
console.log('Loading EDL file', type, path, append);
|
||||||
@ -1594,7 +1283,7 @@ function App() {
|
|||||||
// Need to check if file is actually readable
|
// Need to check if file is actually readable
|
||||||
const pathReadAccessErrorCode = await getPathReadAccessError(fp);
|
const pathReadAccessErrorCode = await getPathReadAccessError(fp);
|
||||||
if (pathReadAccessErrorCode != null) {
|
if (pathReadAccessErrorCode != null) {
|
||||||
let errorMessage;
|
let errorMessage: string | undefined;
|
||||||
if (pathReadAccessErrorCode === 'ENOENT') errorMessage = i18n.t('The media you tried to open does not exist');
|
if (pathReadAccessErrorCode === 'ENOENT') errorMessage = i18n.t('The media you tried to open does not exist');
|
||||||
else if (['EACCES', 'EPERM'].includes(pathReadAccessErrorCode)) errorMessage = i18n.t('You do not have permission to access this file');
|
else if (['EACCES', 'EPERM'].includes(pathReadAccessErrorCode)) errorMessage = i18n.t('You do not have permission to access this file');
|
||||||
else errorMessage = i18n.t('Could not open media due to error {{errorCode}}', { errorCode: pathReadAccessErrorCode });
|
else errorMessage = i18n.t('Could not open media due to error {{errorCode}}', { errorCode: pathReadAccessErrorCode });
|
||||||
@ -1711,8 +1400,8 @@ function App() {
|
|||||||
const seekClosestKeyframe = useCallback((direction: number) => {
|
const seekClosestKeyframe = useCallback((direction: number) => {
|
||||||
const time = findNearestKeyFrameTime({ time: getRelevantTime(), direction });
|
const time = findNearestKeyFrameTime({ time: getRelevantTime(), direction });
|
||||||
if (time == null) return;
|
if (time == null) return;
|
||||||
userSeekAbs(time);
|
seekAbs(time);
|
||||||
}, [findNearestKeyFrameTime, getRelevantTime, userSeekAbs]);
|
}, [findNearestKeyFrameTime, getRelevantTime, seekAbs]);
|
||||||
|
|
||||||
const seekAccelerationRef = useRef(1);
|
const seekAccelerationRef = useRef(1);
|
||||||
|
|
||||||
@ -1765,7 +1454,7 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
}
|
}
|
||||||
}, [userOpenSingleFile, setWorking, filePath]);
|
}, [workingRef, filePath, setWorking, userOpenSingleFile]);
|
||||||
|
|
||||||
const batchFileJump = useCallback((direction: number, alsoOpen: boolean) => {
|
const batchFileJump = useCallback((direction: number, alsoOpen: boolean) => {
|
||||||
if (batchFiles.length === 0) return;
|
if (batchFiles.length === 0) return;
|
||||||
@ -1811,16 +1500,16 @@ function App() {
|
|||||||
if (timecode === undefined) return;
|
if (timecode === undefined) return;
|
||||||
|
|
||||||
if (timecode.relDirection != null) seekRel(timecode.duration * timecode.relDirection);
|
if (timecode.relDirection != null) seekRel(timecode.duration * timecode.relDirection);
|
||||||
else userSeekAbs(timecode.duration);
|
else seekAbs(timecode.duration);
|
||||||
}, [filePath, formatTimecode, parseTimecode, seekRel, timecodePlaceholder, userSeekAbs]);
|
}, [filePath, formatTimecode, commandedTimeRef, timecodePlaceholder, parseTimecode, seekRel, seekAbs]);
|
||||||
|
|
||||||
const goToTimecodeDirect = useCallback(async ({ time: timeStr }: { time: string }) => {
|
const goToTimecodeDirect = useCallback(async ({ time: timeStr }: { time: string }) => {
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
invariant(timeStr != null);
|
invariant(timeStr != null);
|
||||||
const timecode = parseTimecode(timeStr);
|
const timecode = parseTimecode(timeStr);
|
||||||
invariant(timecode != null);
|
invariant(timecode != null);
|
||||||
userSeekAbs(timecode);
|
seekAbs(timecode);
|
||||||
}, [filePath, parseTimecode, userSeekAbs]);
|
}, [filePath, parseTimecode, seekAbs]);
|
||||||
|
|
||||||
const toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []);
|
const toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []);
|
||||||
|
|
||||||
@ -1854,8 +1543,7 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
}
|
}
|
||||||
}, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainCopiedStreams, setWorking, showOsNotification]);
|
}, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainCopiedStreams, setWorking, showOsNotification, workingRef]);
|
||||||
|
|
||||||
|
|
||||||
const userHtml5ifyCurrentFile = useCallback(async ({ ignoreRememberedValue }: { ignoreRememberedValue?: boolean } = {}) => {
|
const userHtml5ifyCurrentFile = useCallback(async ({ ignoreRememberedValue }: { ignoreRememberedValue?: boolean } = {}) => {
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
@ -1887,7 +1575,7 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
}
|
}
|
||||||
}, [customOutDir, filePath, html5ifyAndLoad, hasVideo, hasAudio, rememberConvertToSupportedFormat, setWorking]);
|
}, [filePath, rememberConvertToSupportedFormat, workingRef, hasAudio, hasVideo, setWorking, html5ifyAndLoad, customOutDir]);
|
||||||
|
|
||||||
const askStartTimeOffset = useCallback(async () => {
|
const askStartTimeOffset = useCallback(async () => {
|
||||||
const newStartTimeOffset = await promptTimecode({
|
const newStartTimeOffset = await promptTimecode({
|
||||||
@ -1922,7 +1610,7 @@ function App() {
|
|||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
setCutProgress(undefined);
|
setCutProgress(undefined);
|
||||||
}
|
}
|
||||||
}, [checkFileOpened, customOutDir, duration, fileFormat, fixInvalidDuration, loadMedia, setWorking, showNotification]);
|
}, [checkFileOpened, customOutDir, duration, fileFormat, fixInvalidDuration, loadMedia, setWorking, showNotification, workingRef]);
|
||||||
|
|
||||||
const addStreamSourceFile = useCallback(async (path: string) => {
|
const addStreamSourceFile = useCallback(async (path: string) => {
|
||||||
if (allFilesMeta[path]) return undefined; // Already added?
|
if (allFilesMeta[path]) return undefined; // Already added?
|
||||||
@ -1999,6 +1687,7 @@ function App() {
|
|||||||
const firstFileStat = await lstat(firstFilePath);
|
const firstFileStat = await lstat(firstFilePath);
|
||||||
if (firstFileStat.isDirectory()) {
|
if (firstFileStat.isDirectory()) {
|
||||||
console.log('Reading directory...');
|
console.log('Reading directory...');
|
||||||
|
invariant(firstFilePath != null);
|
||||||
filePaths = await readDirRecursively(firstFilePath);
|
filePaths = await readDirRecursively(firstFilePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2110,7 +1799,7 @@ function App() {
|
|||||||
console.error('userOpenFiles', err);
|
console.error('userOpenFiles', err);
|
||||||
handleError(i18n.t('Failed to open file'), err);
|
handleError(i18n.t('Failed to open file'), err);
|
||||||
}
|
}
|
||||||
}, [alwaysConcatMultipleFiles, batchLoadPaths, setWorking, isFileOpened, batchFiles.length, userOpenSingleFile, checkFileOpened, loadEdlFile, enableAskForFileOpenAction, addStreamSourceFile, filePath]);
|
}, [workingRef, alwaysConcatMultipleFiles, batchLoadPaths, setWorking, isFileOpened, batchFiles.length, userOpenSingleFile, checkFileOpened, loadEdlFile, enableAskForFileOpenAction, addStreamSourceFile, filePath]);
|
||||||
|
|
||||||
const openFilesDialog = useCallback(async () => {
|
const openFilesDialog = useCallback(async () => {
|
||||||
// On Windows and Linux an open dialog can not be both a file selector and a directory selector, so if you set `properties` to `['openFile', 'openDirectory']` on these platforms, a directory selector will be shown. #1995
|
// On Windows and Linux an open dialog can not be both a file selector and a directory selector, so if you set `properties` to `['openFile', 'openDirectory']` on these platforms, a directory selector will be shown. #1995
|
||||||
@ -2167,7 +1856,7 @@ function App() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to toggle fullscreen', err);
|
console.error('Failed to toggle fullscreen', err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [videoContainerRef, videoRef]);
|
||||||
|
|
||||||
const onEditSegmentTags = useCallback((index: number) => {
|
const onEditSegmentTags = useCallback((index: number) => {
|
||||||
setEditingSegmentTagsSegmentIndex(index);
|
setEditingSegmentTagsSegmentIndex(index);
|
||||||
@ -2436,7 +2125,7 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
}
|
}
|
||||||
}, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainStreams, setWorking, showOsNotification]);
|
}, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainStreams, setWorking, showOsNotification, workingRef]);
|
||||||
|
|
||||||
const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]);
|
const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]);
|
||||||
|
|
||||||
@ -2477,37 +2166,37 @@ function App() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
}
|
}
|
||||||
}, [fileUri, usingPreviewFile, filePath, setWorking, hasVideo, hasAudio, html5ifyAndLoadWithPreferences, customOutDir, showUnsupportedFileMessage]);
|
}, [videoRef, fileUri, usingPreviewFile, filePath, workingRef, setWorking, hasVideo, hasAudio, html5ifyAndLoadWithPreferences, customOutDir, showUnsupportedFileMessage]);
|
||||||
|
|
||||||
const onVideoFocus = useCallback((e) => {
|
const onVideoFocus = useCallback<FocusEventHandler<HTMLVideoElement>>((e) => {
|
||||||
// prevent video element from stealing focus in fullscreen mode https://github.com/mifi/lossless-cut/issues/543#issuecomment-1868167775
|
// prevent video element from stealing focus in fullscreen mode https://github.com/mifi/lossless-cut/issues/543#issuecomment-1868167775
|
||||||
e.target.blur();
|
e.target.blur();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onVideoClick = useCallback(() => togglePlay(), [togglePlay]);
|
const onVideoClick = useCallback(() => togglePlay(), [togglePlay]);
|
||||||
|
|
||||||
|
const tryExportEdlFile = useCallback(async (type: EdlExportType) => {
|
||||||
|
if (!checkFileOpened()) return;
|
||||||
|
try {
|
||||||
|
await exportEdlFile({ type, cutSegments: selectedSegments, customOutDir, filePath, getFrameCount });
|
||||||
|
} catch (err) {
|
||||||
|
errorToast(i18n.t('Failed to export project'));
|
||||||
|
console.error('Failed to export project', type, err);
|
||||||
|
}
|
||||||
|
}, [checkFileOpened, customOutDir, filePath, getFrameCount, selectedSegments]);
|
||||||
|
|
||||||
|
const importEdlFile = useCallback(async (type: EdlImportType) => {
|
||||||
|
if (!checkFileOpened()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const edl = await askForEdlImport({ type, fps: detectedFps });
|
||||||
|
if (edl.length > 0) loadCutSegments(edl, true);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err);
|
||||||
|
}
|
||||||
|
}, [checkFileOpened, detectedFps, loadCutSegments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function tryExportEdlFile(type: EdlExportType) {
|
|
||||||
if (!checkFileOpened()) return;
|
|
||||||
try {
|
|
||||||
await exportEdlFile({ type, cutSegments: selectedSegments, customOutDir, filePath, getFrameCount });
|
|
||||||
} catch (err) {
|
|
||||||
errorToast(i18n.t('Failed to export project'));
|
|
||||||
console.error('Failed to export project', type, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importEdlFile(type: EdlImportType) {
|
|
||||||
if (!checkFileOpened()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const edl = await askForEdlImport({ type, fps: detectedFps });
|
|
||||||
if (edl.length > 0) loadCutSegments(edl, true);
|
|
||||||
} catch (err) {
|
|
||||||
handleError(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openFiles = (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); };
|
const openFiles = (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); };
|
||||||
|
|
||||||
async function actionWithCatch(fn: () => void) {
|
async function actionWithCatch(fn: () => void) {
|
||||||
@ -2595,7 +2284,7 @@ function App() {
|
|||||||
ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action));
|
ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action));
|
||||||
electron.ipcRenderer.off('apiAction', tryApiAction);
|
electron.ipcRenderer.off('apiAction', tryApiAction);
|
||||||
};
|
};
|
||||||
}, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, goToTimecodeDirect, loadCutSegments, mainActions, promptDownloadMediaUrlWrapper, selectedSegments, toggleKeyboardShortcuts, userOpenFiles]);
|
}, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, goToTimecodeDirect, importEdlFile, loadCutSegments, mainActions, promptDownloadMediaUrlWrapper, selectedSegments, toggleKeyboardShortcuts, tryExportEdlFile, userOpenFiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function onDrop(ev: DragEvent) {
|
async function onDrop(ev: DragEvent) {
|
||||||
@ -2630,7 +2319,7 @@ function App() {
|
|||||||
}, [customFfPath]);
|
}, [customFfPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyScrollPreventer = (e) => {
|
const keyScrollPreventer = (e: KeyboardEvent) => {
|
||||||
// https://stackoverflow.com/questions/8916620/disable-arrow-key-scrolling-in-users-browser
|
// https://stackoverflow.com/questions/8916620/disable-arrow-key-scrolling-in-users-browser
|
||||||
if (e.target === document.body && [32, 37, 38, 39, 40].includes(e.keyCode)) {
|
if (e.target === document.body && [32, 37, 38, 39, 40].includes(e.keyCode)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -2643,8 +2332,6 @@ function App() {
|
|||||||
|
|
||||||
const showLeftBar = batchFiles.length > 0;
|
const showLeftBar = batchFiles.length > 0;
|
||||||
|
|
||||||
const thumbnailsSorted = useMemo(() => sortBy(thumbnails, (thumbnail) => thumbnail.time), [thumbnails]);
|
|
||||||
|
|
||||||
function renderSubtitles() {
|
function renderSubtitles() {
|
||||||
if (!activeSubtitle) return null;
|
if (!activeSubtitle) return null;
|
||||||
return <track default kind="subtitles" label={activeSubtitle.lang} srcLang="en" src={activeSubtitle.url} />;
|
return <track default kind="subtitles" label={activeSubtitle.lang} srcLang="en" src={activeSubtitle.url} />;
|
||||||
@ -2765,7 +2452,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{working && <Working text={working.text} cutProgress={cutProgress} onAbortClick={handleAbortWorkingClick} />}
|
{working && <Working text={working.text} cutProgress={cutProgress} onAbortClick={abortWorking} />}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{tunerVisible && <ValueTuners type={tunerVisible} onFinished={() => setTunerVisible(undefined)} />}
|
{tunerVisible && <ValueTuners type={tunerVisible} onFinished={() => setTunerVisible(undefined)} />}
|
||||||
@ -2831,7 +2518,7 @@ function App() {
|
|||||||
commandedTimeRef={commandedTimeRef}
|
commandedTimeRef={commandedTimeRef}
|
||||||
startTimeOffset={startTimeOffset}
|
startTimeOffset={startTimeOffset}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
seekAbs={userSeekAbs}
|
seekAbs={seekAbs}
|
||||||
durationSafe={durationSafe}
|
durationSafe={durationSafe}
|
||||||
apparentCutSegments={apparentCutSegments}
|
apparentCutSegments={apparentCutSegments}
|
||||||
setCurrentSegIndex={setCurrentSegIndex}
|
setCurrentSegIndex={setCurrentSegIndex}
|
||||||
@ -2860,7 +2547,7 @@ function App() {
|
|||||||
captureSnapshot={captureSnapshot}
|
captureSnapshot={captureSnapshot}
|
||||||
onExportPress={onExportPress}
|
onExportPress={onExportPress}
|
||||||
segmentsToExport={segmentsToExport}
|
segmentsToExport={segmentsToExport}
|
||||||
seekAbs={userSeekAbs}
|
seekAbs={seekAbs}
|
||||||
currentSegIndexSafe={currentSegIndexSafe}
|
currentSegIndexSafe={currentSegIndexSafe}
|
||||||
cutSegments={cutSegments}
|
cutSegments={cutSegments}
|
||||||
currentCutSeg={currentCutSeg}
|
currentCutSeg={currentCutSeg}
|
||||||
|
@ -51,7 +51,7 @@ function Settings({
|
|||||||
}: {
|
}: {
|
||||||
onTunerRequested: (type: TunerType) => void,
|
onTunerRequested: (type: TunerType) => void,
|
||||||
onKeyboardShortcutsDialogRequested: () => void,
|
onKeyboardShortcutsDialogRequested: () => void,
|
||||||
askForCleanupChoices: () => Promise<void>,
|
askForCleanupChoices: () => Promise<unknown>,
|
||||||
toggleStoreProjectInWorkingDir: () => Promise<void>,
|
toggleStoreProjectInWorkingDir: () => Promise<void>,
|
||||||
simpleMode: boolean,
|
simpleMode: boolean,
|
||||||
clearOutDir: () => Promise<void>,
|
clearOutDir: () => Promise<void>,
|
||||||
|
@ -386,13 +386,25 @@ export async function confirmExtractAllStreamsDialog() {
|
|||||||
return !!value;
|
return !!value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CleanupChoices = ({ cleanupChoicesInitial, onChange: onChangeProp }) => {
|
export interface CleanupChoicesType {
|
||||||
|
trashTmpFiles: boolean,
|
||||||
|
closeFile: boolean,
|
||||||
|
askForCleanup: boolean,
|
||||||
|
cleanupAfterExport?: boolean | undefined,
|
||||||
|
trashSourceFile?: boolean,
|
||||||
|
trashProjectFile?: boolean,
|
||||||
|
deleteIfTrashFails?: boolean,
|
||||||
|
}
|
||||||
|
export type CleanupChoice = keyof CleanupChoicesType;
|
||||||
|
|
||||||
|
|
||||||
|
const CleanupChoices = ({ cleanupChoicesInitial, onChange: onChangeProp }: { cleanupChoicesInitial: CleanupChoicesType, onChange: (v: CleanupChoicesType) => void }) => {
|
||||||
const [choices, setChoices] = useState(cleanupChoicesInitial);
|
const [choices, setChoices] = useState(cleanupChoicesInitial);
|
||||||
|
|
||||||
const getVal = (key) => !!choices[key];
|
const getVal = (key: CleanupChoice) => !!choices[key];
|
||||||
|
|
||||||
const onChange = (key, val) => setChoices((oldChoices) => {
|
const onChange = (key: CleanupChoice, val: boolean | string) => setChoices((oldChoices) => {
|
||||||
const newChoices = { ...oldChoices, [key]: val };
|
const newChoices = { ...oldChoices, [key]: Boolean(val) };
|
||||||
if ((newChoices.trashSourceFile || newChoices.trashTmpFiles) && !newChoices.closeFile) {
|
if ((newChoices.trashSourceFile || newChoices.trashTmpFiles) && !newChoices.closeFile) {
|
||||||
newChoices.closeFile = true;
|
newChoices.closeFile = true;
|
||||||
}
|
}
|
||||||
@ -429,10 +441,10 @@ const CleanupChoices = ({ cleanupChoicesInitial, onChange: onChangeProp }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function showCleanupFilesDialog(cleanupChoicesIn) {
|
export async function showCleanupFilesDialog(cleanupChoicesIn: CleanupChoicesType) {
|
||||||
let cleanupChoices = cleanupChoicesIn;
|
let cleanupChoices = cleanupChoicesIn;
|
||||||
|
|
||||||
const { value } = await ReactSwal.fire({
|
const { value } = await ReactSwal.fire<string>({
|
||||||
title: i18n.t('Cleanup files?'),
|
title: i18n.t('Cleanup files?'),
|
||||||
html: <CleanupChoices cleanupChoicesInitial={cleanupChoices} onChange={(newChoices) => { cleanupChoices = newChoices; }} />,
|
html: <CleanupChoices cleanupChoicesInitial={cleanupChoices} onChange={(newChoices) => { cleanupChoices = newChoices; }} />,
|
||||||
confirmButtonText: i18n.t('Confirm'),
|
confirmButtonText: i18n.t('Confirm'),
|
||||||
|
31
src/renderer/src/hooks/useLoading.ts
Normal file
31
src/renderer/src/hooks/useLoading.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { abortFfmpegs } from '../ffmpeg';
|
||||||
|
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [working, setWorkingState] = useState<{ text: string, abortController?: AbortController | undefined }>();
|
||||||
|
|
||||||
|
// Store "working" in a ref so we can avoid race conditions
|
||||||
|
const workingRef = useRef(!!working);
|
||||||
|
const setWorking = useCallback((valOrBool?: { text: string, abortController?: AbortController } | true | undefined) => {
|
||||||
|
workingRef.current = !!valOrBool;
|
||||||
|
const val = valOrBool === true ? { text: t('Loading') } : valOrBool;
|
||||||
|
setWorkingState(val ? { text: val.text, abortController: val.abortController } : undefined);
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const abortWorking = useCallback(() => {
|
||||||
|
console.log('User clicked abort');
|
||||||
|
abortFfmpegs(); // todo use abortcontroller for this also
|
||||||
|
working?.abortController?.abort();
|
||||||
|
}, [working?.abortController]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
working,
|
||||||
|
workingRef,
|
||||||
|
setWorking,
|
||||||
|
abortWorking,
|
||||||
|
};
|
||||||
|
};
|
69
src/renderer/src/hooks/useSegmentsAutoSave.ts
Normal file
69
src/renderer/src/hooks/useSegmentsAutoSave.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useDebounce } from 'use-debounce';
|
||||||
|
import isEqual from 'lodash/isEqual';
|
||||||
|
|
||||||
|
import isDev from '../isDev';
|
||||||
|
import { saveLlcProject } from '../edlStore';
|
||||||
|
import { createSegment, getCleanCutSegments } from '../segments';
|
||||||
|
import { getSuffixedOutPath } from '../util';
|
||||||
|
import { StateSegment } from '../types';
|
||||||
|
import { errorToast } from '../swal';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
|
|
||||||
|
export default ({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments }: {
|
||||||
|
autoSaveProjectFile: boolean,
|
||||||
|
storeProjectInWorkingDir: boolean,
|
||||||
|
filePath: string | undefined,
|
||||||
|
customOutDir: string | undefined,
|
||||||
|
cutSegments: StateSegment[],
|
||||||
|
}) => {
|
||||||
|
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 }), []);
|
||||||
|
// Old versions of LosslessCut used CSV files and stored them always in customOutDir:
|
||||||
|
const getEdlFilePathOld = useCallback((fp: string | undefined, cod?: string | undefined) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: oldProjectSuffix }), []);
|
||||||
|
const getProjectFileSavePath = useCallback((storeProjectInWorkingDirIn: boolean) => getEdlFilePath(filePath, storeProjectInWorkingDirIn ? customOutDir : undefined), [getEdlFilePath, filePath, customOutDir]);
|
||||||
|
const projectFileSavePath = useMemo(() => getProjectFileSavePath(storeProjectInWorkingDir), [getProjectFileSavePath, storeProjectInWorkingDir]);
|
||||||
|
|
||||||
|
const currentSaveOperation = useMemo(() => {
|
||||||
|
if (!projectFileSavePath) return undefined;
|
||||||
|
return { cutSegments, projectFileSavePath, filePath };
|
||||||
|
}, [cutSegments, filePath, projectFileSavePath]);
|
||||||
|
|
||||||
|
const [debouncedSaveOperation] = useDebounce(currentSaveOperation, isDev ? 2000 : 500);
|
||||||
|
|
||||||
|
const lastSaveOperation = useRef<typeof debouncedSaveOperation>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function save() {
|
||||||
|
// NOTE: Could lose a save if user closes too fast, but not a big issue I think
|
||||||
|
if (!autoSaveProjectFile || !debouncedSaveOperation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initial state? Don't save (same as createInitialCutSegments but without counting)
|
||||||
|
if (isEqual(getCleanCutSegments(debouncedSaveOperation.cutSegments), getCleanCutSegments([createSegment()]))) return;
|
||||||
|
|
||||||
|
if (lastSaveOperation.current && lastSaveOperation.current.projectFileSavePath === debouncedSaveOperation.projectFileSavePath && isEqual(getCleanCutSegments(lastSaveOperation.current.cutSegments), getCleanCutSegments(debouncedSaveOperation.cutSegments))) {
|
||||||
|
console.log('Segments unchanged, skipping save');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveLlcProject({ savePath: debouncedSaveOperation.projectFileSavePath, filePath: debouncedSaveOperation.filePath, cutSegments: debouncedSaveOperation.cutSegments });
|
||||||
|
lastSaveOperation.current = debouncedSaveOperation;
|
||||||
|
} catch (err) {
|
||||||
|
errorToast(i18n.t('Unable to save project file'));
|
||||||
|
console.error('Failed to save project file', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save();
|
||||||
|
}, [debouncedSaveOperation, autoSaveProjectFile]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getEdlFilePath,
|
||||||
|
getEdlFilePathOld,
|
||||||
|
projectFileSavePath,
|
||||||
|
getProjectFileSavePath,
|
||||||
|
};
|
||||||
|
};
|
70
src/renderer/src/hooks/useStreamsMeta.ts
Normal file
70
src/renderer/src/hooks/useStreamsMeta.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import invariant from 'tiny-invariant';
|
||||||
|
|
||||||
|
import { isStreamThumbnail, shouldCopyStreamByDefault } from '../util/streams';
|
||||||
|
import StreamsSelector from '../StreamsSelector';
|
||||||
|
import { FFprobeStream } from '../../../../ffprobe';
|
||||||
|
|
||||||
|
|
||||||
|
export default ({ mainStreams, filePath, autoExportExtraStreams }: {
|
||||||
|
mainStreams: FFprobeStream[],
|
||||||
|
filePath: string | undefined,
|
||||||
|
autoExportExtraStreams: boolean,
|
||||||
|
}) => {
|
||||||
|
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState<Record<string, Record<string, boolean>>>({});
|
||||||
|
|
||||||
|
const isCopyingStreamId = useCallback((path: string | undefined, streamId: number) => (
|
||||||
|
!!((path != null && copyStreamIdsByFile[path]) || {})[streamId]
|
||||||
|
), [copyStreamIdsByFile]);
|
||||||
|
|
||||||
|
const mainCopiedStreams = useMemo(() => mainStreams.filter((stream) => isCopyingStreamId(filePath, stream.index)), [filePath, isCopyingStreamId, mainStreams]);
|
||||||
|
const mainCopiedThumbnailStreams = useMemo(() => mainCopiedStreams.filter((stream) => isStreamThumbnail(stream)), [mainCopiedStreams]);
|
||||||
|
|
||||||
|
// Streams that are not copy enabled by default
|
||||||
|
const extraStreams = useMemo(() => mainStreams.filter((stream) => !shouldCopyStreamByDefault(stream)), [mainStreams]);
|
||||||
|
|
||||||
|
// Extra streams that the user has not selected for copy
|
||||||
|
const nonCopiedExtraStreams = useMemo(() => extraStreams.filter((stream) => !isCopyingStreamId(filePath, stream.index)), [extraStreams, filePath, isCopyingStreamId]);
|
||||||
|
|
||||||
|
const exportExtraStreams = autoExportExtraStreams && nonCopiedExtraStreams.length > 0;
|
||||||
|
|
||||||
|
const copyFileStreams = useMemo(() => Object.entries(copyStreamIdsByFile).map(([path, streamIdsMap]) => ({
|
||||||
|
path,
|
||||||
|
streamIds: Object.entries(streamIdsMap).filter(([, shouldCopy]) => shouldCopy).map(([streamIdStr]) => parseInt(streamIdStr, 10)),
|
||||||
|
})), [copyStreamIdsByFile]);
|
||||||
|
|
||||||
|
// total number of streams to copy for ALL files
|
||||||
|
const numStreamsToCopy = useMemo(() => copyFileStreams.reduce((acc, { streamIds }) => acc + streamIds.length, 0), [copyFileStreams]);
|
||||||
|
|
||||||
|
const setCopyStreamIdsForPath = useCallback<Parameters<typeof StreamsSelector>[0]['setCopyStreamIdsForPath']>((path, cb) => {
|
||||||
|
setCopyStreamIdsByFile((old) => {
|
||||||
|
const oldIds = old[path] || {};
|
||||||
|
return ({ ...old, [path]: cb(oldIds) });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkCopyingAnyTrackOfType = useCallback((filter: (s: FFprobeStream) => boolean) => mainStreams.some((stream) => isCopyingStreamId(filePath, stream.index) && filter(stream)), [filePath, isCopyingStreamId, mainStreams]);
|
||||||
|
|
||||||
|
const toggleStripStream = useCallback((filter) => {
|
||||||
|
const copyingAnyTrackOfType = checkCopyingAnyTrackOfType(filter);
|
||||||
|
invariant(filePath != null);
|
||||||
|
setCopyStreamIdsForPath(filePath, (old) => {
|
||||||
|
const newCopyStreamIds = { ...old };
|
||||||
|
mainStreams.forEach((stream) => {
|
||||||
|
if (filter(stream)) newCopyStreamIds[stream.index] = !copyingAnyTrackOfType;
|
||||||
|
});
|
||||||
|
return newCopyStreamIds;
|
||||||
|
});
|
||||||
|
}, [checkCopyingAnyTrackOfType, filePath, mainStreams, setCopyStreamIdsForPath]);
|
||||||
|
|
||||||
|
const toggleStripAudio = useCallback(() => toggleStripStream((stream) => stream.codec_type === 'audio'), [toggleStripStream]);
|
||||||
|
const toggleStripThumbnail = useCallback(() => toggleStripStream(isStreamThumbnail), [toggleStripStream]);
|
||||||
|
|
||||||
|
const copyAnyAudioTrack = useMemo(() => checkCopyingAnyTrackOfType((stream) => stream.codec_type === 'audio'), [checkCopyingAnyTrackOfType]);
|
||||||
|
|
||||||
|
const toggleCopyStreamId = useCallback((path: string, index: number) => {
|
||||||
|
setCopyStreamIdsForPath(path, (old) => ({ ...old, [index]: !old[index] }));
|
||||||
|
}, [setCopyStreamIdsForPath]);
|
||||||
|
|
||||||
|
return { nonCopiedExtraStreams, exportExtraStreams, mainCopiedThumbnailStreams, numStreamsToCopy, toggleStripAudio, toggleStripThumbnail, copyAnyAudioTrack, copyStreamIdsByFile, setCopyStreamIdsByFile, copyFileStreams, mainCopiedStreams, setCopyStreamIdsForPath, toggleCopyStreamId, isCopyingStreamId };
|
||||||
|
};
|
31
src/renderer/src/hooks/useSubtitles.ts
Normal file
31
src/renderer/src/hooks/useSubtitles.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { extractSubtitleTrackVtt } from '../ffmpeg';
|
||||||
|
import { FFprobeStream } from '../../../../ffprobe';
|
||||||
|
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [subtitlesByStreamId, setSubtitlesByStreamId] = useState<Record<string, { url: string, lang?: string }>>({});
|
||||||
|
|
||||||
|
const loadSubtitle = useCallback(async ({ filePath, index, subtitleStream }: { filePath: string, index: number, subtitleStream: FFprobeStream }) => {
|
||||||
|
const url = await extractSubtitleTrackVtt(filePath, index);
|
||||||
|
setSubtitlesByStreamId((old) => ({ ...old, [index]: { url, lang: subtitleStream.tags && subtitleStream.tags.language } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup removed subtitles
|
||||||
|
const subtitlesByStreamIdRef = useRef<typeof subtitlesByStreamId>({});
|
||||||
|
useEffect(() => {
|
||||||
|
Object.values(subtitlesByStreamIdRef.current).forEach(({ url, lang }) => {
|
||||||
|
if (!Object.values(subtitlesByStreamId).some((existingSubtitle) => existingSubtitle.url === url)) {
|
||||||
|
console.log('Cleanup subtitle', lang);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
subtitlesByStreamIdRef.current = subtitlesByStreamId;
|
||||||
|
}, [subtitlesByStreamId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadSubtitle,
|
||||||
|
subtitlesByStreamId,
|
||||||
|
setSubtitlesByStreamId,
|
||||||
|
};
|
||||||
|
};
|
65
src/renderer/src/hooks/useThumbnails.ts
Normal file
65
src/renderer/src/hooks/useThumbnails.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out this
|
||||||
|
import invariant from 'tiny-invariant';
|
||||||
|
import sortBy from 'lodash/sortBy';
|
||||||
|
|
||||||
|
import { renderThumbnails as ffmpegRenderThumbnails } from '../ffmpeg';
|
||||||
|
import { Thumbnail } from '../types';
|
||||||
|
import { isDurationValid } from '../segments';
|
||||||
|
|
||||||
|
|
||||||
|
export default ({ filePath, zoomedDuration, zoomWindowStartTime, showThumbnails }: {
|
||||||
|
filePath: string | undefined,
|
||||||
|
zoomedDuration: number | undefined,
|
||||||
|
zoomWindowStartTime: number,
|
||||||
|
showThumbnails: boolean,
|
||||||
|
}) => {
|
||||||
|
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
|
||||||
|
const thumnailsRef = useRef<Thumbnail[]>([]);
|
||||||
|
const thumnailsRenderingPromiseRef = useRef<Promise<void>>();
|
||||||
|
|
||||||
|
function addThumbnail(thumbnail) {
|
||||||
|
// console.log('Rendered thumbnail', thumbnail.url);
|
||||||
|
setThumbnails((v) => [...v, thumbnail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, cancelRenderThumbnails] = useDebounceOld(() => {
|
||||||
|
async function renderThumbnails() {
|
||||||
|
if (!showThumbnails || thumnailsRenderingPromiseRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setThumbnails([]);
|
||||||
|
invariant(filePath != null);
|
||||||
|
invariant(zoomedDuration != null);
|
||||||
|
const promise = ffmpegRenderThumbnails({ filePath, from: zoomWindowStartTime, duration: zoomedDuration, onThumbnail: addThumbnail });
|
||||||
|
thumnailsRenderingPromiseRef.current = promise;
|
||||||
|
await promise;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to render thumbnail', err);
|
||||||
|
} finally {
|
||||||
|
thumnailsRenderingPromiseRef.current = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDurationValid(zoomedDuration)) renderThumbnails();
|
||||||
|
}, 500, [zoomedDuration, filePath, zoomWindowStartTime, showThumbnails]);
|
||||||
|
|
||||||
|
// Cleanup removed thumbnails
|
||||||
|
useEffect(() => {
|
||||||
|
thumnailsRef.current.forEach((thumbnail) => {
|
||||||
|
if (!thumbnails.some((nextThumbnail) => nextThumbnail.url === thumbnail.url)) {
|
||||||
|
console.log('Cleanup thumbnail', thumbnail.time);
|
||||||
|
URL.revokeObjectURL(thumbnail.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
thumnailsRef.current = thumbnails;
|
||||||
|
}, [thumbnails]);
|
||||||
|
|
||||||
|
const thumbnailsSorted = useMemo(() => sortBy(thumbnails, (thumbnail) => thumbnail.time), [thumbnails]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnailsSorted,
|
||||||
|
setThumbnails,
|
||||||
|
cancelRenderThumbnails,
|
||||||
|
};
|
||||||
|
};
|
57
src/renderer/src/hooks/useTimecode.ts
Normal file
57
src/renderer/src/hooks/useTimecode.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { FormatTimecode, ParseTimecode } from '../types';
|
||||||
|
import { getFrameCountRaw } from '../edlFormats';
|
||||||
|
import { getFrameDuration } from '../util';
|
||||||
|
import { TimecodeFormat } from '../../../../types';
|
||||||
|
import { formatDuration, parseDuration } from '../util/duration';
|
||||||
|
|
||||||
|
|
||||||
|
export default ({ detectedFps, timecodeFormat }: {
|
||||||
|
detectedFps: number | undefined,
|
||||||
|
timecodeFormat: TimecodeFormat,
|
||||||
|
}) => {
|
||||||
|
const getFrameCount = useCallback((sec: number) => getFrameCountRaw(detectedFps, sec), [detectedFps]);
|
||||||
|
const frameCountToDuration = useCallback((frames: number) => getFrameDuration(detectedFps) * frames, [detectedFps]);
|
||||||
|
|
||||||
|
const formatTimecode = useCallback<FormatTimecode>(({ seconds, shorten, fileNameFriendly }) => {
|
||||||
|
if (timecodeFormat === 'frameCount') {
|
||||||
|
const frameCount = getFrameCount(seconds);
|
||||||
|
return frameCount != null ? String(frameCount) : '';
|
||||||
|
}
|
||||||
|
if (timecodeFormat === 'timecodeWithFramesFraction') {
|
||||||
|
return formatDuration({ seconds, shorten, fileNameFriendly, fps: detectedFps });
|
||||||
|
}
|
||||||
|
return formatDuration({ seconds, shorten, fileNameFriendly });
|
||||||
|
}, [detectedFps, timecodeFormat, getFrameCount]);
|
||||||
|
|
||||||
|
const timecodePlaceholder = useMemo(() => formatTimecode({ seconds: 0, shorten: false }), [formatTimecode]);
|
||||||
|
|
||||||
|
const parseTimecode = useCallback<ParseTimecode>((val: string) => {
|
||||||
|
if (timecodeFormat === 'frameCount') {
|
||||||
|
const parsed = parseInt(val, 10);
|
||||||
|
return frameCountToDuration(parsed);
|
||||||
|
}
|
||||||
|
if (timecodeFormat === 'timecodeWithFramesFraction') {
|
||||||
|
return parseDuration(val, detectedFps);
|
||||||
|
}
|
||||||
|
return parseDuration(val);
|
||||||
|
}, [detectedFps, frameCountToDuration, timecodeFormat]);
|
||||||
|
|
||||||
|
const formatTimeAndFrames = useCallback((seconds: number) => {
|
||||||
|
const frameCount = getFrameCount(seconds);
|
||||||
|
|
||||||
|
const timeStr = timecodeFormat === 'timecodeWithFramesFraction'
|
||||||
|
? formatDuration({ seconds, fps: detectedFps })
|
||||||
|
: formatDuration({ seconds });
|
||||||
|
|
||||||
|
return `${timeStr} (${frameCount ?? '0'})`;
|
||||||
|
}, [detectedFps, timecodeFormat, getFrameCount]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
parseTimecode,
|
||||||
|
formatTimecode,
|
||||||
|
formatTimeAndFrames,
|
||||||
|
timecodePlaceholder,
|
||||||
|
getFrameCount,
|
||||||
|
};
|
||||||
|
};
|
166
src/renderer/src/hooks/useVideo.ts
Normal file
166
src/renderer/src/hooks/useVideo.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { ReactEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ChromiumHTMLVideoElement, PlaybackMode } from '../types';
|
||||||
|
import { isDurationValid } from '../segments';
|
||||||
|
import { showPlaybackFailedMessage } from '../swal';
|
||||||
|
|
||||||
|
export default ({ filePath }: { filePath: string | undefined }) => {
|
||||||
|
const [commandedTime, setCommandedTime] = useState(0);
|
||||||
|
const [compatPlayerEventId, setCompatPlayerEventId] = useState(0);
|
||||||
|
const [playbackRate, setPlaybackRateState] = useState(1);
|
||||||
|
const [outputPlaybackRate, setOutputPlaybackRateState] = useState(1);
|
||||||
|
const [playerTime, setPlayerTime] = useState<number>();
|
||||||
|
const [duration, setDuration] = useState<number>();
|
||||||
|
const playbackModeRef = useRef<PlaybackMode>();
|
||||||
|
|
||||||
|
const videoRef = useRef<ChromiumHTMLVideoElement>(null);
|
||||||
|
const videoContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const setPlaybackRate = useCallback((rate: number) => {
|
||||||
|
if (videoRef.current) videoRef.current.playbackRate = rate;
|
||||||
|
setPlaybackRateState(rate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setOutputPlaybackRate = useCallback((rate: number) => {
|
||||||
|
setOutputPlaybackRateState(rate);
|
||||||
|
if (videoRef.current) videoRef.current.playbackRate = rate;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const playingRef = useRef(false);
|
||||||
|
|
||||||
|
// https://kitchen.vibbio.com/blog/optimizing-html5-video-scrubbing/
|
||||||
|
const seekingRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
const seekToRef = useRef<number>();
|
||||||
|
|
||||||
|
const smoothSeek = useCallback((seekTo: number) => {
|
||||||
|
if (seekingRef.current) {
|
||||||
|
seekToRef.current = seekTo;
|
||||||
|
} else {
|
||||||
|
videoRef.current!.currentTime = seekTo;
|
||||||
|
// safety precaution:
|
||||||
|
seekingRef.current = setTimeout(() => {
|
||||||
|
seekingRef.current = undefined;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSeeked = useCallback<ReactEventHandler<HTMLVideoElement>>(() => {
|
||||||
|
if (seekToRef.current != null) {
|
||||||
|
videoRef.current!.currentTime = seekToRef.current;
|
||||||
|
seekToRef.current = undefined;
|
||||||
|
} else {
|
||||||
|
clearTimeout(seekingRef.current);
|
||||||
|
seekingRef.current = undefined;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seekAbs = useCallback((val: number | undefined) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video == null || val == null || Number.isNaN(val)) return;
|
||||||
|
let outVal = val;
|
||||||
|
if (outVal < 0) outVal = 0;
|
||||||
|
if (outVal > video.duration) outVal = video.duration;
|
||||||
|
|
||||||
|
smoothSeek(outVal);
|
||||||
|
setCommandedTime(outVal);
|
||||||
|
setCompatPlayerEventId((id) => id + 1); // To make sure that we can seek even to the same commanded time that we are already add (e.g. loop current segment)
|
||||||
|
}, [smoothSeek]);
|
||||||
|
|
||||||
|
const seekRel = useCallback((val: number) => {
|
||||||
|
seekAbs(videoRef.current!.currentTime + val);
|
||||||
|
}, [seekAbs, videoRef]);
|
||||||
|
|
||||||
|
const commandedTimeRef = useRef(commandedTime);
|
||||||
|
useEffect(() => {
|
||||||
|
commandedTimeRef.current = commandedTime;
|
||||||
|
}, [commandedTime]);
|
||||||
|
|
||||||
|
// Relevant time is the player's playback position if we're currently playing - if not, it's the user's commanded time.
|
||||||
|
const relevantTime = useMemo(() => (playing ? playerTime : commandedTime) || 0, [commandedTime, playerTime, playing]);
|
||||||
|
// The reason why we also have a getter is because it can be used when we need to get the time, but don't want to re-render for every time update (which can be heavy!)
|
||||||
|
const getRelevantTime = useCallback(() => (playingRef.current ? videoRef.current!.currentTime : commandedTimeRef.current) || 0, []);
|
||||||
|
|
||||||
|
const onPlayingChange = useCallback((val: boolean) => {
|
||||||
|
playingRef.current = val;
|
||||||
|
setPlaying(val);
|
||||||
|
if (!val) {
|
||||||
|
setCommandedTime(videoRef.current!.currentTime);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onStopPlaying = useCallback(() => {
|
||||||
|
onPlayingChange(false);
|
||||||
|
}, [onPlayingChange]);
|
||||||
|
|
||||||
|
const onVideoAbort = useCallback(() => {
|
||||||
|
setPlaying(false); // we want to preserve current time https://github.com/mifi/lossless-cut/issues/1674#issuecomment-1658937716
|
||||||
|
playbackModeRef.current = undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSartPlaying = useCallback(() => onPlayingChange(true), [onPlayingChange]);
|
||||||
|
const onDurationChange = useCallback<ReactEventHandler<HTMLVideoElement>>((e) => {
|
||||||
|
// Some files report duration infinity first, then proper duration later
|
||||||
|
// Sometimes after seeking to end of file, duration might change
|
||||||
|
const { duration: durationNew } = e.currentTarget;
|
||||||
|
console.log('onDurationChange', durationNew);
|
||||||
|
if (isDurationValid(durationNew)) setDuration(durationNew);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (!filePath || !playingRef.current) return;
|
||||||
|
videoRef.current!.pause();
|
||||||
|
}, [filePath, playingRef, videoRef]);
|
||||||
|
|
||||||
|
const play = useCallback((resetPlaybackRate?: boolean) => {
|
||||||
|
if (!filePath || playingRef.current) return;
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
|
||||||
|
// This was added to re-sync time if file gets reloaded #1674 - but I had to remove this because it broke loop-selected-segments https://github.com/mifi/lossless-cut/discussions/1785#discussioncomment-7852134
|
||||||
|
// if (Math.abs(commandedTimeRef.current - video.currentTime) > 1) video.currentTime = commandedTimeRef.current;
|
||||||
|
|
||||||
|
if (resetPlaybackRate) setPlaybackRate(outputPlaybackRate);
|
||||||
|
video?.play().catch((err) => {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError' && 'code' in err && err.code === 20) { // Probably "DOMException: The play() request was interrupted by a call to pause()."
|
||||||
|
console.error(err);
|
||||||
|
} else {
|
||||||
|
showPlaybackFailedMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [filePath, outputPlaybackRate, playingRef, setPlaybackRate, videoRef]);
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoRef,
|
||||||
|
videoContainerRef,
|
||||||
|
playbackRate,
|
||||||
|
setPlaybackRate,
|
||||||
|
outputPlaybackRate,
|
||||||
|
setOutputPlaybackRate,
|
||||||
|
commandedTime,
|
||||||
|
setCommandedTime,
|
||||||
|
commandedTimeRef,
|
||||||
|
playing,
|
||||||
|
setPlaying,
|
||||||
|
playingRef,
|
||||||
|
onStopPlaying,
|
||||||
|
onSartPlaying,
|
||||||
|
onSeeked,
|
||||||
|
seekAbs,
|
||||||
|
seekRel,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
relevantTime,
|
||||||
|
getRelevantTime,
|
||||||
|
duration,
|
||||||
|
setDuration,
|
||||||
|
onDurationChange,
|
||||||
|
onVideoAbort,
|
||||||
|
compatPlayerEventId,
|
||||||
|
setCompatPlayerEventId,
|
||||||
|
setOutputPlaybackRateState,
|
||||||
|
playbackModeRef,
|
||||||
|
playerTime,
|
||||||
|
setPlayerTime,
|
||||||
|
};
|
||||||
|
};
|
@ -96,7 +96,10 @@ export default ({ darkMode, filePath, relevantTime, duration, waveformEnabled, a
|
|||||||
// Cleanup old
|
// Cleanup old
|
||||||
// if (removedWaveforms.length > 0) console.log('cleanup waveforms', removedWaveforms.length);
|
// if (removedWaveforms.length > 0) console.log('cleanup waveforms', removedWaveforms.length);
|
||||||
removedWaveforms.forEach((waveform) => {
|
removedWaveforms.forEach((waveform) => {
|
||||||
if (waveform.url != null) URL.revokeObjectURL(waveform.url);
|
if (waveform.url != null) {
|
||||||
|
console.log('Cleanup waveform', waveform.from, waveform.to);
|
||||||
|
URL.revokeObjectURL(waveform.url);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
lastWaveformsRef.current = waveforms;
|
lastWaveformsRef.current = waveforms;
|
||||||
}, [waveforms]);
|
}, [waveforms]);
|
||||||
|
6
src/renderer/src/styles.ts
Normal file
6
src/renderer/src/styles.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { controlsBackground, darkModeTransition } from './colors';
|
||||||
|
|
||||||
|
|
||||||
|
export const videoStyle: CSSProperties = { width: '100%', height: '100%', objectFit: 'contain' };
|
||||||
|
export const bottomStyle: CSSProperties = { background: controlsBackground, transition: darkModeTransition };
|
@ -1,6 +1,7 @@
|
|||||||
import SwalRaw from 'sweetalert2/dist/sweetalert2.js';
|
import SwalRaw from 'sweetalert2/dist/sweetalert2.js';
|
||||||
import type { SweetAlertOptions } from 'sweetalert2';
|
import type { SweetAlertOptions } from 'sweetalert2';
|
||||||
import withReactContent from 'sweetalert2-react-content';
|
import withReactContent from 'sweetalert2-react-content';
|
||||||
|
import i18n from './i18n';
|
||||||
|
|
||||||
|
|
||||||
const { systemPreferences } = window.require('@electron/remote');
|
const { systemPreferences } = window.require('@electron/remote');
|
||||||
@ -55,4 +56,6 @@ export const errorToast = (text: string) => toast.fire({
|
|||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const showPlaybackFailedMessage = () => errorToast(i18n.t('Unable to playback this file. Try to convert to supported format from the menu'));
|
||||||
|
|
||||||
export const ReactSwal = withReactContent(Swal);
|
export const ReactSwal = withReactContent(Swal);
|
||||||
|
@ -279,7 +279,7 @@ export function getHtml5ifiedPath(cod: string | undefined, fp, type) {
|
|||||||
return getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${type}.${ext}` });
|
return getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${type}.${ext}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFiles({ paths, deleteIfTrashFails, signal }: { paths: string[], deleteIfTrashFails?: boolean, signal: AbortSignal }) {
|
export async function deleteFiles({ paths, deleteIfTrashFails, signal }: { paths: string[], deleteIfTrashFails?: boolean | undefined, signal: AbortSignal }) {
|
||||||
const failedToTrashFiles: string[] = [];
|
const failedToTrashFiles: string[] = [];
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
@ -429,7 +429,7 @@ export function mustDisallowVob() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readVideoTs(videoTsPath) {
|
export async function readVideoTs(videoTsPath: string) {
|
||||||
const files = await readdir(videoTsPath);
|
const files = await readdir(videoTsPath);
|
||||||
const relevantFiles = files.filter((file) => /^vts_\d+_\d+\.vob$/i.test(file) && !/^vts_\d+_00\.vob$/i.test(file)); // skip menu
|
const relevantFiles = files.filter((file) => /^vts_\d+_\d+\.vob$/i.test(file) && !/^vts_\d+_00\.vob$/i.test(file)); // skip menu
|
||||||
const ret = sortBy(relevantFiles).map((file) => join(videoTsPath, file));
|
const ret = sortBy(relevantFiles).map((file) => join(videoTsPath, file));
|
||||||
@ -437,7 +437,7 @@ export async function readVideoTs(videoTsPath) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readDirRecursively(dirPath) {
|
export async function readDirRecursively(dirPath: string) {
|
||||||
const files = await readdir(dirPath, { recursive: true });
|
const files = await readdir(dirPath, { recursive: true });
|
||||||
const ret = (await pMap(files, async (path) => {
|
const ret = (await pMap(files, async (path) => {
|
||||||
if (['.DS_Store'].includes(basename(path))) return [];
|
if (['.DS_Store'].includes(basename(path))) return [];
|
||||||
@ -453,7 +453,7 @@ export async function readDirRecursively(dirPath) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getImportProjectType(filePath) {
|
export function getImportProjectType(filePath: string) {
|
||||||
if (filePath.endsWith('Summary.txt')) return 'dv-analyzer-summary-txt';
|
if (filePath.endsWith('Summary.txt')) return 'dv-analyzer-summary-txt';
|
||||||
const edlFormatForExtension = { csv: 'csv', pbf: 'pbf', edl: 'mplayer', cue: 'cue', xml: 'xmeml', fcpxml: 'fcpxml' };
|
const edlFormatForExtension = { csv: 'csv', pbf: 'pbf', edl: 'mplayer', cue: 'cue', xml: 'xmeml', fcpxml: 'fcpxml' };
|
||||||
const matchingExt = Object.keys(edlFormatForExtension).find((ext) => filePath.toLowerCase().endsWith(`.${ext}`));
|
const matchingExt = Object.keys(edlFormatForExtension).find((ext) => filePath.toLowerCase().endsWith(`.${ext}`));
|
||||||
@ -461,7 +461,7 @@ export function getImportProjectType(filePath) {
|
|||||||
return edlFormatForExtension[matchingExt];
|
return edlFormatForExtension[matchingExt];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calcShouldShowWaveform = (zoomedDuration) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8);
|
export const calcShouldShowWaveform = (zoomedDuration: number | undefined) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8);
|
||||||
export const calcShouldShowKeyframes = (zoomedDuration) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8);
|
export const calcShouldShowKeyframes = (zoomedDuration: number | undefined) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8);
|
||||||
|
|
||||||
export const mediaSourceQualities = ['HD', 'SD', 'OG']; // OG is original
|
export const mediaSourceQualities = ['HD', 'SD', 'OG']; // OG is original
|
||||||
|
Loading…
Reference in New Issue
Block a user