1
0
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:
Mikael Finstad 2024-08-17 00:39:00 +02:00
parent 1c320f423d
commit 2dac700f40
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
14 changed files with 635 additions and 435 deletions

View File

@ -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}

View File

@ -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>,

View File

@ -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'),

View 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,
};
};

View 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,
};
};

View 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 };
};

View 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,
};
};

View 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,
};
};

View 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,
};
};

View 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,
};
};

View File

@ -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]);

View 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 };

View File

@ -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);

View File

@ -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