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 { MdRotate90DegreesCcw } from 'react-icons/md';
import { AnimatePresence } from 'framer-motion';
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 { useTranslation } from 'react-i18next';
import { produce } from 'immer';
@ -12,9 +10,6 @@ import screenfull from 'screenfull';
import { IpcRendererEvent } from 'electron';
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 invariant from 'tiny-invariant';
import { SweetAlertOptions } from 'sweetalert2';
@ -54,45 +49,52 @@ import Working from './components/Working';
import OutputFormatSelect from './components/OutputFormatSelect';
import { loadMifiLink, runStartupCheck } from './mifi';
import { controlsBackground, darkModeTransition } from './colors';
import { darkModeTransition } from './colors';
import { getSegColor } from './util/colors';
import {
getStreamFps, isCuttingStart, isCuttingEnd,
readFileMeta, getSmarterOutFormat, renderThumbnails as ffmpegRenderThumbnails,
readFileMeta, getSmarterOutFormat,
extractStreams, setCustomFfPath as ffmpegSetCustomFfPath,
isIphoneHevc, isProblematicAvc1, tryMapChaptersToEdl,
getDuration, getTimecodeFromStreams, createChaptersFromSegments, extractSubtitleTrackVtt,
RefuseOverwriteError, abortFfmpegs, extractSubtitleTrackToSegments,
getDuration, getTimecodeFromStreams, createChaptersFromSegments,
RefuseOverwriteError, extractSubtitleTrackToSegments,
} from './ffmpeg';
import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, isStreamThumbnail, getSubtitleStreams, getVideoTrackForStreamIndex, getAudioTrackForStreamIndex, enableVideoTrack, enableAudioTrack } from './util/streams';
import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore';
import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, getSubtitleStreams, getVideoTrackForStreamIndex, getAudioTrackForStreamIndex, enableVideoTrack, enableAudioTrack } from './util/streams';
import { exportEdlFile, readEdlFile, loadLlcProject, askForEdlImport } from './edlStore';
import { formatYouTube, getFrameCountRaw, formatTsv } from './edlFormats';
import {
getOutPath, getSuffixedOutPath, handleError, getOutDir,
isStoreBuild, dragPreventer,
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType,
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, getFrameDuration, isExecaError, getStdioString,
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, isExecaError, getStdioString,
isMuxNotSupported,
getDownloadMediaOutPath,
} from './util';
import { toast, errorToast } from './swal';
import { formatDuration, parseDuration } from './util/duration';
import { toast, errorToast, showPlaybackFailedMessage } from './swal';
import { adjustRate } from './util/rate-calculator';
import { askExtractFramesAsImages } from './dialogs/extractFrames';
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 { 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 { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants';
import BigWaveform from './components/BigWaveform';
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 { 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 { 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 videoStyle: CSSProperties = { width: '100%', height: '100%', objectFit: 'contain' };
const bottomStyle: CSSProperties = { background: controlsBackground, transition: darkModeTransition };
const hevcPlaybackSupportedPromise = doesPlayerSupportHevcPlayback();
// eslint-disable-next-line unicorn/prefer-top-level-await
hevcPlaybackSupportedPromise.catch((err) => console.error(err));
@ -114,16 +113,9 @@ function App() {
const { t } = useTranslation();
// Per project state
const [commandedTime, setCommandedTime] = useState(0);
const [ffmpegCommandLog, setFfmpegCommandLog] = useState<FfmpegCommandLog>([]);
const [previewFilePath, setPreviewFilePath] = useState<string>();
const [working, setWorkingState] = useState<{ text: string, abortController?: AbortController | undefined }>();
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 [cutProgress, setCutProgress] = useState<number>();
const [startTimeOffset, setStartTimeOffset] = useState(0);
@ -133,14 +125,11 @@ function App() {
const [paramsByStreamId, setParamsByStreamId] = useState<ParamsByStreamId>(new Map());
const [detectedFps, setDetectedFps] = useState<number>();
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 [concatDialogVisible, setConcatDialogVisible] = useState(false);
const [zoomUnrounded, setZoom] = useState(1);
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
const [shortestFlag, setShortestFlag] = useState(false);
const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0);
const [subtitlesByStreamId, setSubtitlesByStreamId] = useState<Record<string, { url: string, lang?: string }>>({});
const [activeVideoStreamIndex, setActiveVideoStreamIndex] = useState<number>();
const [activeAudioStreamIndex, setActiveAudioStreamIndex] = useState<number>();
const [activeSubtitleStreamIndex, setActiveSubtitleStreamIndex] = useState<number>();
@ -148,8 +137,6 @@ function App() {
const [exportConfirmVisible, setExportConfirmVisible] = useState(false);
const [cacheBuster, setCacheBuster] = useState(0);
const [mergedOutFileName, setMergedOutFileName] = useState<string>();
const [playbackRate, setPlaybackRateState] = useState(1);
const [outputPlaybackRate, setOutputPlaybackRateState] = useState(1);
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
@ -177,35 +164,25 @@ function App() {
const [batchFiles, setBatchFiles] = useState<{ path: 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 {
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;
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(() => {
ffmpegSetCustomFfPath(customFfPath);
}, [customFfPath]);
@ -218,22 +195,10 @@ function App() {
electron.ipcRenderer.send('setLanguage', l);
}, [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 onOutputFormatUserChange = useCallback((newFormat) => {
const onOutputFormatUserChange = useCallback((newFormat: string) => {
setFileFormat(newFormat);
if (outFormatLocked) {
setOutFormatLocked(newFormat === detectedFileFormat ? undefined : newFormat);
@ -285,19 +250,8 @@ function App() {
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 toggleCopyStreamId = useCallback((path: string, index: number) => {
setCopyStreamIdsForPath(path, (old) => ({ ...old, [index]: !old[index] }));
}, [setCopyStreamIdsForPath]);
const toggleWaveformMode = useCallback(() => {
if (waveformMode === 'waveform') {
setWaveformMode('big-waveform');
@ -316,62 +270,13 @@ function App() {
useEffect(() => {
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 mainFileFormatData = useMemo(() => mainFileMeta?.formatData, [mainFileMeta?.formatData]);
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 videoStreams = useMemo(() => getRealVideoStreams(mainStreams), [mainStreams]);
const audioStreams = useMemo(() => getAudioStreams(mainStreams), [mainStreams]);
@ -409,12 +314,6 @@ function App() {
});
}, [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;
@ -424,47 +323,13 @@ function App() {
return false;
}, [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 {
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 });
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 segmentsAtCursorIndexes = findSegmentsAtCursor(apparentCutSegments, commandedTime);
@ -479,12 +344,6 @@ function App() {
segmentAtCursorRef.current = 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) => {
if (!isDurationValid(zoomedDuration)) return;
seekRel(val * zoomedDuration);
@ -500,15 +359,15 @@ function App() {
const currentTimeNearestFrameNumber = getFrameCountRaw(fps, videoRef.current!.currentTime);
invariant(currentTimeNearestFrameNumber != null);
const nextFrame = currentTimeNearestFrameNumber + direction;
userSeekAbs(nextFrame / fps);
}, [detectedFps, userSeekAbs]);
seekAbs(nextFrame / fps);
}, [detectedFps, seekAbs, videoRef]);
const jumpSegStart = useCallback((index: number) => userSeekAbs(apparentCutSegments[index]!.start), [apparentCutSegments, userSeekAbs]);
const jumpSegEnd = useCallback((index: number) => userSeekAbs(apparentCutSegments[index]!.end), [apparentCutSegments, userSeekAbs]);
const jumpSegStart = useCallback((index: number) => seekAbs(apparentCutSegments[index]!.start), [apparentCutSegments, seekAbs]);
const jumpSegEnd = useCallback((index: number) => seekAbs(apparentCutSegments[index]!.end), [apparentCutSegments, seekAbs]);
const jumpCutStart = useCallback(() => jumpSegStart(currentSegIndexSafe), [currentSegIndexSafe, jumpSegStart]);
const jumpCutEnd = useCallback(() => jumpSegEnd(currentSegIndexSafe), [currentSegIndexSafe, jumpSegEnd]);
const jumpTimelineStart = useCallback(() => userSeekAbs(0), [userSeekAbs]);
const jumpTimelineEnd = useCallback(() => userSeekAbs(durationSafe), [durationSafe, userSeekAbs]);
const jumpTimelineStart = useCallback(() => seekAbs(0), [seekAbs]);
const jumpTimelineEnd = useCallback(() => seekAbs(durationSafe), [durationSafe, seekAbs]);
const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ formatTimecode, treatOutputFileModifiedTimeAsStart });
@ -530,72 +389,6 @@ function App() {
return uri;
}, [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(() => {
setRotation((r) => (r + 90) % 450);
@ -699,47 +492,27 @@ function App() {
try {
setWorking({ text: i18n.t('Loading subtitle') });
invariant(filePath != null);
const url = await extractSubtitleTrackVtt(filePath, index);
setSubtitlesByStreamId((old) => ({ ...old, [index]: { url, lang: subtitleStream.tags && subtitleStream.tags.language } }));
await loadSubtitle({ filePath, index, subtitleStream });
setActiveSubtitleStreamIndex(index);
} catch (err) {
handleError(`Failed to extract subtitles for stream ${index}`, err instanceof Error && err.message);
} finally {
setWorking(undefined);
}
}, [setWorking, subtitleStreams, subtitlesByStreamId, filePath]);
}, [subtitlesByStreamId, subtitleStreams, workingRef, setWorking, filePath, loadSubtitle]);
const onActiveVideoStreamChange = useCallback((index?: number) => {
invariant(videoRef.current);
setHideMediaSourcePlayer(index == null || getVideoTrackForStreamIndex(videoRef.current, index) != null);
enableVideoTrack(videoRef.current, index);
setActiveVideoStreamIndex(index);
}, []);
}, [videoRef]);
const onActiveAudioStreamChange = useCallback((index?: number) => {
invariant(videoRef.current);
setHideMediaSourcePlayer(index == null || getAudioTrackForStreamIndex(videoRef.current, index) != null);
enableAudioTrack(videoRef.current, index);
setActiveAudioStreamIndex(index);
}, []);
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]);
}, [videoRef]);
const allFilesMeta = useMemo(() => ({
...externalFilesMeta,
@ -747,30 +520,7 @@ function App() {
}), [externalFilesMeta, filePath, mainFileMeta]);
// total number of streams for ALL files
const numStreamsTotal = flatMap(Object.values(allFilesMeta), ({ 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 numStreamsTotal = Object.values(allFilesMeta).flatMap(({ streams }) => streams).length;
const hasAudio = !!activeAudioStream;
const hasVideo = !!activeVideoStream;
@ -779,43 +529,7 @@ function App() {
const bigWaveformEnabled = waveformEnabled && waveformMode === 'big-waveform';
const showThumbnails = thumbnailsEnabled && hasVideo;
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)) 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 { cancelRenderThumbnails, thumbnailsSorted, setThumbnails } = useThumbnails({ filePath, zoomedDuration, zoomWindowStartTime, showThumbnails });
const shouldShowKeyframes = keyframesEnabled && hasVideo && calcShouldShowKeyframes(zoomedDuration);
const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration);
@ -877,7 +591,7 @@ function App() {
setOutputPlaybackRateState(1);
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(() => {
@ -970,7 +684,7 @@ function App() {
setWorking(undefined);
setCutProgress(undefined);
}
}, [batchFiles, customOutDir, ensureWritableOutDir, html5ify, setWorking]);
}, [batchFiles, customOutDir, ensureWritableOutDir, html5ify, setWorking, workingRef]);
const getConvertToSupportedFormat = useCallback((fallback) => rememberConvertToSupportedFormat || fallback, [rememberConvertToSupportedFormat]);
@ -980,34 +694,9 @@ function App() {
await html5ifyAndLoad(cod, fp, getConvertToSupportedFormat(speed), hv, ha);
}, [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 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 = {}) => {
playbackModeRef.current = requestPlaybackMode;
@ -1032,7 +721,7 @@ function App() {
}
}
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 { currentTime } = e.currentTarget;
@ -1062,7 +751,7 @@ function App() {
}
}
}
}, [getApparentCutSegmentById, pause, playerTime, seekAbs, selectedSegments]);
}, [getApparentCutSegmentById, pause, playbackModeRef, playerTime, seekAbs, selectedSegments, setPlayerTime]);
const closeFileWithConfirm = useCallback(() => {
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;
resetState();
}, [askBeforeClose, resetState, isFileOpened]);
}, [isFileOpened, workingRef, askBeforeClose, resetState]);
const closeBatch = useCallback(() => {
// eslint-disable-next-line no-alert
@ -1137,7 +826,7 @@ function App() {
}, [fileFormat, openSendConcatReportDialogWithState]);
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;
try {
@ -1153,7 +842,7 @@ function App() {
const outPath = getOutPath({ customOutDir: newCustomOutDir, filePath: firstPath, fileName: outFileName });
let chaptersFromSegments;
let chaptersFromSegments: Awaited<ReturnType<typeof createChaptersFromSegments>>;
if (segmentsToChapters) {
const chapterNames = paths.map((path) => parsePath(path).name);
chaptersFromSegments = await createChaptersFromSegments({ segmentPaths: paths, chapterNames });
@ -1212,9 +901,9 @@ function App() {
setWorking(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
const savedPaths = { previewFilePath, sourceFilePath: filePath, projectFilePath: projectFileSavePath };
@ -1250,7 +939,7 @@ function App() {
}, [cleanupChoices, setCleanupChoices]);
const cleanupFilesWithDialog = useCallback(async () => {
let response = cleanupChoices;
let response: CleanupChoicesType | undefined = cleanupChoices;
if (cleanupChoices.askForCleanup) {
response = await askForCleanupChoices();
console.log('trashResponse', response);
@ -1269,7 +958,7 @@ function App() {
} finally {
setWorking(undefined);
}
}, [cleanupFilesWithDialog, isFileOpened, setWorking]);
}, [cleanupFilesWithDialog, isFileOpened, setWorking, workingRef]);
const generateOutSegFileNames = useCallback(async ({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
if (fileFormat == null || outputDir == null || filePath == null) throw new Error();
@ -1440,7 +1129,7 @@ function App() {
setWorking(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 () => {
if (!filePath) return;
@ -1470,7 +1159,7 @@ function App() {
console.error(err);
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[]) => {
if (!filePath || detectedFps == null || workingRef.current) return;
@ -1512,7 +1201,7 @@ function App() {
setWorking(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 extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentFramesAsImages(selectedSegments.map((seg) => seg.segId)), [extractSegmentFramesAsImages, selectedSegments]);
@ -1530,7 +1219,7 @@ function App() {
const newRate = adjustRate(video!.playbackRate, dir, rateMultiplier);
setPlaybackRate(newRate);
}
}, [compatPlayerEnabled, setPlaybackRate]);
}, [compatPlayerEnabled, playingRef, setPlaybackRate, videoRef]);
const loadEdlFile = useCallback(async ({ path, type, append }: { path: string, type: EdlFileType, append?: boolean }) => {
console.log('Loading EDL file', type, path, append);
@ -1594,7 +1283,7 @@ function App() {
// Need to check if file is actually readable
const pathReadAccessErrorCode = await getPathReadAccessError(fp);
if (pathReadAccessErrorCode != null) {
let errorMessage;
let errorMessage: string | undefined;
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 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 time = findNearestKeyFrameTime({ time: getRelevantTime(), direction });
if (time == null) return;
userSeekAbs(time);
}, [findNearestKeyFrameTime, getRelevantTime, userSeekAbs]);
seekAbs(time);
}, [findNearestKeyFrameTime, getRelevantTime, seekAbs]);
const seekAccelerationRef = useRef(1);
@ -1765,7 +1454,7 @@ function App() {
} finally {
setWorking(undefined);
}
}, [userOpenSingleFile, setWorking, filePath]);
}, [workingRef, filePath, setWorking, userOpenSingleFile]);
const batchFileJump = useCallback((direction: number, alsoOpen: boolean) => {
if (batchFiles.length === 0) return;
@ -1811,16 +1500,16 @@ function App() {
if (timecode === undefined) return;
if (timecode.relDirection != null) seekRel(timecode.duration * timecode.relDirection);
else userSeekAbs(timecode.duration);
}, [filePath, formatTimecode, parseTimecode, seekRel, timecodePlaceholder, userSeekAbs]);
else seekAbs(timecode.duration);
}, [filePath, formatTimecode, commandedTimeRef, timecodePlaceholder, parseTimecode, seekRel, seekAbs]);
const goToTimecodeDirect = useCallback(async ({ time: timeStr }: { time: string }) => {
if (!filePath) return;
invariant(timeStr != null);
const timecode = parseTimecode(timeStr);
invariant(timecode != null);
userSeekAbs(timecode);
}, [filePath, parseTimecode, userSeekAbs]);
seekAbs(timecode);
}, [filePath, parseTimecode, seekAbs]);
const toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []);
@ -1854,8 +1543,7 @@ function App() {
} finally {
setWorking(undefined);
}
}, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainCopiedStreams, setWorking, showOsNotification]);
}, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainCopiedStreams, setWorking, showOsNotification, workingRef]);
const userHtml5ifyCurrentFile = useCallback(async ({ ignoreRememberedValue }: { ignoreRememberedValue?: boolean } = {}) => {
if (!filePath) return;
@ -1887,7 +1575,7 @@ function App() {
} finally {
setWorking(undefined);
}
}, [customOutDir, filePath, html5ifyAndLoad, hasVideo, hasAudio, rememberConvertToSupportedFormat, setWorking]);
}, [filePath, rememberConvertToSupportedFormat, workingRef, hasAudio, hasVideo, setWorking, html5ifyAndLoad, customOutDir]);
const askStartTimeOffset = useCallback(async () => {
const newStartTimeOffset = await promptTimecode({
@ -1922,7 +1610,7 @@ function App() {
setWorking(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) => {
if (allFilesMeta[path]) return undefined; // Already added?
@ -1999,6 +1687,7 @@ function App() {
const firstFileStat = await lstat(firstFilePath);
if (firstFileStat.isDirectory()) {
console.log('Reading directory...');
invariant(firstFilePath != null);
filePaths = await readDirRecursively(firstFilePath);
}
}
@ -2110,7 +1799,7 @@ function App() {
console.error('userOpenFiles', 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 () => {
// 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) {
console.error('Failed to toggle fullscreen', err);
}
}, []);
}, [videoContainerRef, videoRef]);
const onEditSegmentTags = useCallback((index: number) => {
setEditingSegmentTagsSegmentIndex(index);
@ -2436,7 +2125,7 @@ function App() {
} finally {
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]);
@ -2477,37 +2166,37 @@ function App() {
} catch (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
e.target.blur();
}, []);
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(() => {
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))); };
async function actionWithCatch(fn: () => void) {
@ -2595,7 +2284,7 @@ function App() {
ipcActions.forEach(([key, action]) => electron.ipcRenderer.off(key, action));
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(() => {
async function onDrop(ev: DragEvent) {
@ -2630,7 +2319,7 @@ function App() {
}, [customFfPath]);
useEffect(() => {
const keyScrollPreventer = (e) => {
const keyScrollPreventer = (e: KeyboardEvent) => {
// 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)) {
e.preventDefault();
@ -2643,8 +2332,6 @@ function App() {
const showLeftBar = batchFiles.length > 0;
const thumbnailsSorted = useMemo(() => sortBy(thumbnails, (thumbnail) => thumbnail.time), [thumbnails]);
function renderSubtitles() {
if (!activeSubtitle) return null;
return <track default kind="subtitles" label={activeSubtitle.lang} srcLang="en" src={activeSubtitle.url} />;
@ -2765,7 +2452,7 @@ function App() {
)}
<AnimatePresence>
{working && <Working text={working.text} cutProgress={cutProgress} onAbortClick={handleAbortWorkingClick} />}
{working && <Working text={working.text} cutProgress={cutProgress} onAbortClick={abortWorking} />}
</AnimatePresence>
{tunerVisible && <ValueTuners type={tunerVisible} onFinished={() => setTunerVisible(undefined)} />}
@ -2831,7 +2518,7 @@ function App() {
commandedTimeRef={commandedTimeRef}
startTimeOffset={startTimeOffset}
zoom={zoom}
seekAbs={userSeekAbs}
seekAbs={seekAbs}
durationSafe={durationSafe}
apparentCutSegments={apparentCutSegments}
setCurrentSegIndex={setCurrentSegIndex}
@ -2860,7 +2547,7 @@ function App() {
captureSnapshot={captureSnapshot}
onExportPress={onExportPress}
segmentsToExport={segmentsToExport}
seekAbs={userSeekAbs}
seekAbs={seekAbs}
currentSegIndexSafe={currentSegIndexSafe}
cutSegments={cutSegments}
currentCutSeg={currentCutSeg}

View File

@ -51,7 +51,7 @@ function Settings({
}: {
onTunerRequested: (type: TunerType) => void,
onKeyboardShortcutsDialogRequested: () => void,
askForCleanupChoices: () => Promise<void>,
askForCleanupChoices: () => Promise<unknown>,
toggleStoreProjectInWorkingDir: () => Promise<void>,
simpleMode: boolean,
clearOutDir: () => Promise<void>,

View File

@ -386,13 +386,25 @@ export async function confirmExtractAllStreamsDialog() {
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 getVal = (key) => !!choices[key];
const getVal = (key: CleanupChoice) => !!choices[key];
const onChange = (key, val) => setChoices((oldChoices) => {
const newChoices = { ...oldChoices, [key]: val };
const onChange = (key: CleanupChoice, val: boolean | string) => setChoices((oldChoices) => {
const newChoices = { ...oldChoices, [key]: Boolean(val) };
if ((newChoices.trashSourceFile || newChoices.trashTmpFiles) && !newChoices.closeFile) {
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;
const { value } = await ReactSwal.fire({
const { value } = await ReactSwal.fire<string>({
title: i18n.t('Cleanup files?'),
html: <CleanupChoices cleanupChoicesInitial={cleanupChoices} onChange={(newChoices) => { cleanupChoices = newChoices; }} />,
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
// if (removedWaveforms.length > 0) console.log('cleanup waveforms', removedWaveforms.length);
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;
}, [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 type { SweetAlertOptions } from 'sweetalert2';
import withReactContent from 'sweetalert2-react-content';
import i18n from './i18n';
const { systemPreferences } = window.require('@electron/remote');
@ -55,4 +56,6 @@ export const errorToast = (text: string) => toast.fire({
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);

View File

@ -279,7 +279,7 @@ export function getHtml5ifiedPath(cod: string | undefined, fp, type) {
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[] = [];
// eslint-disable-next-line no-restricted-syntax
@ -429,7 +429,7 @@ export function mustDisallowVob() {
return false;
}
export async function readVideoTs(videoTsPath) {
export async function readVideoTs(videoTsPath: string) {
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 ret = sortBy(relevantFiles).map((file) => join(videoTsPath, file));
@ -437,7 +437,7 @@ export async function readVideoTs(videoTsPath) {
return ret;
}
export async function readDirRecursively(dirPath) {
export async function readDirRecursively(dirPath: string) {
const files = await readdir(dirPath, { recursive: true });
const ret = (await pMap(files, async (path) => {
if (['.DS_Store'].includes(basename(path))) return [];
@ -453,7 +453,7 @@ export async function readDirRecursively(dirPath) {
return ret;
}
export function getImportProjectType(filePath) {
export function getImportProjectType(filePath: string) {
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 matchingExt = Object.keys(edlFormatForExtension).find((ext) => filePath.toLowerCase().endsWith(`.${ext}`));
@ -461,7 +461,7 @@ export function getImportProjectType(filePath) {
return edlFormatForExtension[matchingExt];
}
export const calcShouldShowWaveform = (zoomedDuration) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8);
export const calcShouldShowKeyframes = (zoomedDuration) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8);
export const calcShouldShowWaveform = (zoomedDuration: number | undefined) => (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