diff --git a/package.json b/package.json index a1957d91..cb03dc1a 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "luxon": "^3.3.0", "mkdirp": "^1.0.3", "mousetrap": "^1.6.5", + "nanoid": "^5.0.6", "p-map": "^5.5.0", "p-retry": "^6.2.0", "pify": "^5.0.0", @@ -102,7 +103,6 @@ "typescript-plugin-css-modules": "^5.1.0", "use-debounce": "^5.1.0", "use-trace-update": "^1.3.0", - "uuid": "^8.3.2", "vite": "^4.5.2", "vitest": "^1.2.2", "wait-on": "^7.0.1" diff --git a/public/configStore.js b/public/configStore.js index d70f5047..08dba4a3 100644 --- a/public/configStore.js +++ b/public/configStore.js @@ -45,11 +45,13 @@ const defaultKeyBindings = [ { keys: 'ctrl+home', action: 'jumpTimelineStart' }, { keys: 'ctrl+end', action: 'jumpTimelineEnd' }, + { keys: 'pageup', action: 'jumpFirstSegment' }, { keys: 'up', action: 'jumpPrevSegment' }, { keys: 'ctrl+up', action: 'timelineZoomIn' }, { keys: 'command+up', action: 'timelineZoomIn' }, { keys: 'shift+up', action: 'batchPreviousFile' }, + { keys: 'pagedown', action: 'jumpLastSegment' }, { keys: 'down', action: 'jumpNextSegment' }, { keys: 'ctrl+down', action: 'timelineZoomOut' }, { keys: 'command+down', action: 'timelineZoomOut' }, diff --git a/src/App.tsx b/src/App.tsx index 39df4bb5..7cce64f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -85,7 +85,7 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti import BigWaveform from './components/BigWaveform'; import isDev from './isDev'; -import { EdlFileType, FfmpegCommandLog, FfprobeChapter, FfprobeFormat, FfprobeStream, Html5ifyMode, Thumbnail, TunerType } from './types'; +import { EdlFileType, FfmpegCommandLog, FfprobeChapter, FfprobeFormat, FfprobeStream, Html5ifyMode, PlaybackMode, StateSegment, Thumbnail, TunerType } from './types'; const electron = window.require('electron'); const { exists } = window.require('fs-extra'); @@ -111,11 +111,11 @@ function App() { const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]); const [previewFilePath, setPreviewFilePath] = useState(); - const [working, setWorkingState] = useState<{ text: string, abortController: AbortController }>(); + const [working, setWorkingState] = useState<{ text: string, abortController?: AbortController }>(); const [usingDummyVideo, setUsingDummyVideo] = useState(false); const [playing, setPlaying] = useState(false); const [compatPlayerEventId, setCompatPlayerEventId] = useState(0); - const playbackModeRef = useRef<{ playbackMode: 'loop-selected-segments', segId: string }>(); + const playbackModeRef = useRef(); const [playerTime, setPlayerTime] = useState(); const [duration, setDuration] = useState(); const [rotation, setRotation] = useState(360); @@ -171,7 +171,7 @@ function App() { // Store "working" in a ref so we can avoid race conditions const workingRef = useRef(!!working); - const setWorking = useCallback((val) => { + const setWorking = useCallback((val: { text: string, abortController?: AbortController } | undefined) => { workingRef.current = !!val; setWorkingState(val ? { text: val.text, abortController: val.abortController } : undefined); }, []); @@ -297,35 +297,11 @@ function App() { 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) }, []); - const userSeekAbs = useCallback((val: number) => { - playbackModeRef.current = undefined; // If the user seeks, we clear any custom playback mode - return seekAbs(val); - }, [seekAbs]); - const commandedTimeRef = useRef(commandedTime); useEffect(() => { commandedTimeRef.current = commandedTime; }, [commandedTime]); - const seekRel = useCallback((val: number) => { - userSeekAbs(videoRef.current!.currentTime + val); - }, [userSeekAbs]); - - const seekRelPercent = useCallback((val) => { - if (!isDurationValid(zoomedDuration)) return; - seekRel(val * zoomedDuration); - }, [seekRel, zoomedDuration]); - - const shortStep = useCallback((direction) => { - // If we don't know fps, just assume 30 (for example if unknown audio file) - const fps = detectedFps || 30; - - // try to align with frame - const currentTimeNearestFrameNumber = getFrameCountRaw(fps, videoRef.current!.currentTime); - const nextFrame = currentTimeNearestFrameNumber + direction; - userSeekAbs(nextFrame / fps); - }, [detectedFps, userSeekAbs]); - const mainStreams = useMemo(() => mainFileMeta.streams, [mainFileMeta.streams]); const mainFileFormatData = useMemo(() => mainFileMeta.formatData, [mainFileMeta.formatData]); const mainFileChapters = useMemo(() => mainFileMeta.chapters, [mainFileMeta.chapters]); @@ -374,8 +350,6 @@ function App() { }); }, [comfortZoom]); - const onTimelineWheel = useTimelineScroll({ wheelSensitivity, mouseWheelZoomModifierKey, invertTimelineScroll, zoomRel, seekRel }); - // 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!) @@ -393,8 +367,45 @@ function App() { cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByTag, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, } = useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly }); - const jumpSegStart = useCallback((index) => userSeekAbs(apparentCutSegments[index].start), [apparentCutSegments, userSeekAbs]); - const jumpSegEnd = useCallback((index) => userSeekAbs(apparentCutSegments[index].end), [apparentCutSegments, userSeekAbs]); + + const segmentAtCursor = useMemo(() => { + const segmentsAtCursorIndexes = findSegmentsAtCursor(apparentCutSegments, commandedTime); + const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; + + if (firstSegmentAtCursorIndex == null) return undefined; + return cutSegments[firstSegmentAtCursorIndex]; + }, [apparentCutSegments, commandedTime, cutSegments]); + + const segmentAtCursorRef = useRef(); + useEffect(() => { + 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) => { + if (!isDurationValid(zoomedDuration)) return; + seekRel(val * zoomedDuration); + }, [seekRel, zoomedDuration]); + + const onTimelineWheel = useTimelineScroll({ wheelSensitivity, mouseWheelZoomModifierKey, invertTimelineScroll, zoomRel, seekRel }); + + const shortStep = useCallback((direction) => { + // If we don't know fps, just assume 30 (for example if unknown audio file) + const fps = detectedFps || 30; + + // try to align with frame + const currentTimeNearestFrameNumber = getFrameCountRaw(fps, videoRef.current!.currentTime); + const nextFrame = currentTimeNearestFrameNumber + direction; + userSeekAbs(nextFrame / fps); + }, [detectedFps, userSeekAbs]); + + const jumpSegStart = useCallback((index) => userSeekAbs(apparentCutSegments[index]!.start), [apparentCutSegments, userSeekAbs]); + const jumpSegEnd = useCallback((index) => userSeekAbs(apparentCutSegments[index]!.end), [apparentCutSegments, userSeekAbs]); const jumpCutStart = useCallback(() => jumpSegStart(currentSegIndexSafe), [currentSegIndexSafe, jumpSegStart]); const jumpCutEnd = useCallback(() => jumpSegEnd(currentSegIndexSafe), [currentSegIndexSafe, jumpSegEnd]); const jumpTimelineStart = useCallback(() => userSeekAbs(0), [userSeekAbs]); @@ -494,7 +505,6 @@ function App() { const onStopPlaying = useCallback(() => { onPlayingChange(false); - playbackModeRef.current = undefined; }, []); const onVideoAbort = useCallback(() => { @@ -889,8 +899,8 @@ function App() { const showPlaybackFailedMessage = () => errorToast(i18n.t('Unable to playback this file. Try to convert to supported format from the menu')); - const getNewJumpIndex = (oldIndex, direction) => Math.max(oldIndex + direction, 0); - const jumpSeg = useCallback((direction) => setCurrentSegIndex((old) => Math.min(getNewJumpIndex(old, direction), cutSegments.length - 1)), [cutSegments, setCurrentSegIndex]); + 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 || !playing) return; @@ -908,54 +918,58 @@ function App() { if (resetPlaybackRate) video!.playbackRate = outputPlaybackRate; video?.play().catch((err) => { showPlaybackFailedMessage(); - console.error(err); + console.error(err, Object.entries(err)); }); }, [filePath, outputPlaybackRate, playing]); - const togglePlay = useCallback(({ resetPlaybackRate, playbackMode } = {}) => { - playbackModeRef.current = undefined; + const togglePlay = useCallback(({ resetPlaybackRate, requestPlaybackMode }: { resetPlaybackRate?: boolean, requestPlaybackMode?: PlaybackMode } | undefined = {}) => { + playbackModeRef.current = requestPlaybackMode; + if (playing) { pause(); return; } - if (playbackMode != null) { - if (playbackMode === 'loop-selected-segments') { - const firstSelectedSegment = selectedSegments[0]; - playbackModeRef.current = { segId: firstSelectedSegment.segId, playbackMode }; - const index = apparentCutSegments.indexOf(firstSelectedSegment); - if (index >= 0) setCurrentSegIndex(index); - seekAbs(firstSelectedSegment.start); - } else { - playbackModeRef.current = { segId: currentApparentCutSeg.segId, playbackMode }; - seekAbs(currentApparentCutSeg.start); + + if (playbackModeRef.current != null) { + const isSomeSelectedSegmentAtCursor = selectedSegments.some((selectedSegment) => selectedSegment.segId === segmentAtCursorRef.current?.segId); + if (!isSomeSelectedSegmentAtCursor) { // if a segment is already at cursor, don't do anything + if (playbackModeRef.current === 'loop-selected-segments') { + const firstSelectedSegment = selectedSegments[0]; + if (firstSelectedSegment == null) throw new Error(); + const index = apparentCutSegments.indexOf(firstSelectedSegment); + if (index >= 0) setCurrentSegIndex(index); + seekAbs(firstSelectedSegment.start); + } else { + seekAbs(currentApparentCutSeg.start); + } } } play(resetPlaybackRate); - }, [playing, play, pause, selectedSegments, apparentCutSegments, setCurrentSegIndex, seekAbs, currentApparentCutSeg.segId, currentApparentCutSeg.start]); + }, [playing, play, pause, selectedSegments, apparentCutSegments, setCurrentSegIndex, seekAbs, currentApparentCutSeg.start]); const onTimeUpdate = useCallback((e) => { const { currentTime } = e.target; if (playerTime === currentTime) return; setPlayerTime(currentTime); - if (playbackModeRef.current != null) { - const { segId, playbackMode } = playbackModeRef.current; - const playingSegment = getApparentCutSegmentById(segId); + const playbackMode = playbackModeRef.current; + if (playbackMode != null && segmentAtCursorRef.current != null) { // todo and is currently playing? + const playingSegment = getApparentCutSegmentById(segmentAtCursorRef.current.segId); if (playingSegment != null) { - const { seek, stop, nextSegment } = playOnlyCurrentSegment({ playbackMode, currentTime, playingSegment }); - // console.log({ seek, stop, nextSegment }); - - if (nextSegment != null) { + const nextAction = playOnlyCurrentSegment({ playbackMode, currentTime, playingSegment }); + // console.log(nextAction); + if (nextAction.nextSegment) { const index = selectedSegments.indexOf(playingSegment); let newIndex = getNewJumpIndex(index >= 0 ? index : 0, 1); if (newIndex > selectedSegments.length - 1) newIndex = 0; // have reached end of last segment, start over const nextSelectedSegment = selectedSegments[newIndex]; if (nextSelectedSegment != null) seekAbs(nextSelectedSegment.start); - playbackModeRef.current.segId = nextSelectedSegment.segId; } - if (seek != null) seekAbs(seek); - if (stop) { + if (nextAction.seekTo != null) { + seekAbs(nextAction.seekTo); + } + if (nextAction.exit) { playbackModeRef.current = undefined; pause(); } @@ -1241,6 +1255,7 @@ function App() { setCutProgress(0); setWorking({ text: i18n.t('Merging') }); + // @ts-expect-error name only exists for invertCutSegments = false const chapterNames = segmentsToChapters && !invertCutSegments ? segmentsToExport.map((s) => s.name) : undefined; await autoConcatCutSegments({ @@ -1403,14 +1418,6 @@ function App() { } }, [playing, compatPlayerEnabled]); - const segmentAtCursor = useMemo(() => { - const segmentsAtCursorIndexes = findSegmentsAtCursor(apparentCutSegments, commandedTime); - const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; - - if (firstSegmentAtCursorIndex == null) return undefined; - return cutSegments[firstSegmentAtCursorIndex]; - }, [apparentCutSegments, commandedTime, cutSegments]); - const loadEdlFile = useCallback(async ({ path, type, append }: { path: string, type: EdlFileType, append?: boolean }) => { console.log('Loading EDL file', type, path, append); loadCutSegments(await readEdlFile({ type, path }), append); @@ -1965,7 +1972,7 @@ function App() { setConcatDialogVisible(true); }, [batchFiles.length, openFilesDialog]); - const toggleLoopSelectedSegments = useCallback(() => togglePlay({ resetPlaybackRate: true, playbackMode: 'loop-selected-segments' }), [togglePlay]); + const toggleLoopSelectedSegments = useCallback(() => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'loop-selected-segments' }), [togglePlay]); const copySegmentsToClipboard = useCallback(async () => { if (!isFileOpened) return; @@ -2024,9 +2031,9 @@ function App() { // For actions, see also KeyboardShortcuts.jsx togglePlayNoResetSpeed: () => togglePlay(), togglePlayResetSpeed: () => togglePlay({ resetPlaybackRate: true }), - togglePlayOnlyCurrentSegment: () => togglePlay({ resetPlaybackRate: true, playbackMode: 'play-segment-once' }), - toggleLoopOnlyCurrentSegment: () => togglePlay({ resetPlaybackRate: true, playbackMode: 'loop-segment' }), - toggleLoopStartEndOnlyCurrentSegment: () => togglePlay({ resetPlaybackRate: true, playbackMode: 'loop-segment-start-end' }), + togglePlayOnlyCurrentSegment: () => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'play-segment-once' }), + toggleLoopOnlyCurrentSegment: () => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'loop-segment' }), + toggleLoopStartEndOnlyCurrentSegment: () => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'loop-segment-start-end' }), toggleLoopSelectedSegments, play: () => play(), pause, @@ -2067,6 +2074,8 @@ function App() { seekNextFrame: () => shortStep(1), jumpPrevSegment: () => jumpSeg(-1), jumpNextSegment: () => jumpSeg(1), + jumpFirstSegment: () => setCurrentSegIndex(0), + jumpLastSegment: () => setCurrentSegIndex(cutSegments.length - 1), jumpCutStart, jumpCutEnd, jumpTimelineStart, diff --git a/src/components/KeyboardShortcuts.jsx b/src/components/KeyboardShortcuts.jsx index 85381a68..f3aa2a4b 100644 --- a/src/components/KeyboardShortcuts.jsx +++ b/src/components/KeyboardShortcuts.jsx @@ -321,6 +321,14 @@ const KeyboardShortcuts = memo(({ name: t('Jump to next segment'), category: segmentsAndCutpointsCategory, }, + jumpFirstSegment: { + name: t('Jump to first segment'), + category: segmentsAndCutpointsCategory, + }, + jumpLastSegment: { + name: t('Jump to last segment'), + category: segmentsAndCutpointsCategory, + }, reorderSegsByStartTime: { name: t('Reorder segments by start time'), category: segmentsAndCutpointsCategory, diff --git a/src/dialogs/parameters.jsx b/src/dialogs/parameters.tsx similarity index 76% rename from src/dialogs/parameters.jsx rename to src/dialogs/parameters.tsx index 2623a647..c8947515 100644 --- a/src/dialogs/parameters.jsx +++ b/src/dialogs/parameters.tsx @@ -10,14 +10,18 @@ const { shell } = window.require('electron'); const ReactSwal = withReactContent(Swal); +export interface ParameterDialogParameter { value: string, label?: string, hint?: string } +export type ParameterDialogParameters = Record; -const ParametersInput = ({ description, parameters: parametersIn, onChange, onSubmit, docUrl }) => { - const firstInputRef = useRef(); +const ParametersInput = ({ description, parameters: parametersIn, onChange, onSubmit, docUrl }: { + description?: string, parameters: ParameterDialogParameters, onChange: (a: ParameterDialogParameters) => void, onSubmit: () => void, docUrl?: string, +}) => { + const firstInputRef = useRef(null); const [parameters, setParameters] = useState(parametersIn); - const getParameter = (key) => parameters[key]?.value; + const getParameter = (key: string) => parameters[key]?.value; - const handleChange = (key, value) => setParameters((existing) => { + const handleChange = (key: string, value: string) => setParameters((existing) => { const newParameters = { ...existing, [key]: { ...existing[key], value } }; onChange(newParameters); return newParameters; @@ -29,7 +33,7 @@ const ParametersInput = ({ description, parameters: parametersIn, onChange, onSu }, [onSubmit]); useEffect(() => { - firstInputRef.current?.focus?.(); + firstInputRef.current?.focus(); }, []); return ( @@ -50,7 +54,7 @@ const ParametersInput = ({ description, parameters: parametersIn, onChange, onSu }; // eslint-disable-next-line import/prefer-default-export -export async function showParametersDialog({ title, description, parameters: parametersIn, docUrl }) { +export async function showParametersDialog({ title, description, parameters: parametersIn, docUrl }: { title?: string, description?: string, parameters: ParameterDialogParameters, docUrl?: string }) { let parameters = parametersIn; let resolve1; diff --git a/src/edlStore.ts b/src/edlStore.ts index d341d046..bbe3b829 100644 --- a/src/edlStore.ts +++ b/src/edlStore.ts @@ -120,7 +120,7 @@ export async function askForEdlImport({ type, fps }: { type: EdlImportType, fps? } export async function exportEdlFile({ type, cutSegments, customOutDir, filePath, getFrameCount }: { - type: EdlExportType, cutSegments: Segment, customOutDir?: string, filePath?: string, getFrameCount: (a: number) => number | undefined, + type: EdlExportType, cutSegments: Segment[], customOutDir?: string, filePath?: string, getFrameCount: (a: number) => number | undefined, }) { let filters; let ext; diff --git a/src/hooks/useSegments.js b/src/hooks/useSegments.ts similarity index 91% rename from src/hooks/useSegments.js rename to src/hooks/useSegments.ts index 5917d40a..67948d7d 100644 --- a/src/hooks/useSegments.js +++ b/src/hooks/useSegments.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useMemo, useState } from 'react'; +import { useCallback, useRef, useMemo, useState, MutableRefObject } from 'react'; import { useStateWithHistory } from 'react-use/lib/useStateWithHistory'; import i18n from 'i18next'; import pMap from 'p-map'; @@ -13,15 +13,15 @@ import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegmen import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2 } from '../segments'; import * as ffmpegParameters from '../ffmpeg-parameters'; import { maxSegmentsAllowed } from '../util/constants'; +import { StateSegment } from '../types'; const remote = window.require('@electron/remote'); const { blackDetect, silenceDetect } = remote.require('./ffmpeg'); -export default ({ - filePath, workingRef, setWorking, setCutProgress, videoStream, - duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, +export default ({ filePath, workingRef, setWorking, setCutProgress, videoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly }: { + filePath: string, workingRef: MutableRefObject, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, setCutProgress: (a: number | undefined) => void, videoStream, duration?: number, getRelevantTime: () => number, maxLabelLength: number, checkFileOpened: () => boolean, invertCutSegments: boolean, segmentsToChaptersOnly: boolean, }) => { // Segment related state const segCounterRef = useRef(0); @@ -42,7 +42,7 @@ export default ({ const [currentSegIndex, setCurrentSegIndex] = useState(0); const [deselectedSegmentIds, setDeselectedSegmentIds] = useState({}); - const isSegmentSelected = useCallback(({ segId }) => !deselectedSegmentIds[segId], [deselectedSegmentIds]); + const isSegmentSelected = useCallback(({ segId }: { segId: string }) => !deselectedSegmentIds[segId], [deselectedSegmentIds]); const clearSegCounter = useCallback(() => { @@ -93,14 +93,14 @@ export default ({ } catch (err) { handleError(errorText, err); } finally { - setWorking(); - setCutProgress(); + setWorking(undefined); + setCutProgress(undefined); } }, [filePath, workingRef, setWorking, setCutProgress, loadCutSegments]); const getSegApparentEnd = useCallback((seg) => getSegApparentEnd2(seg, duration), [duration]); - const getApparentCutSegments = useCallback((segments) => segments.map((cutSegment) => ({ + const getApparentCutSegments = useCallback((segments: StateSegment[]) => segments.map((cutSegment) => ({ ...cutSegment, start: getSegApparentStart(cutSegment), end: getSegApparentEnd(cutSegment), @@ -114,8 +114,16 @@ export default ({ const haveInvalidSegs = useMemo(() => apparentCutSegments.some((cutSegment) => cutSegment.start >= cutSegment.end), [apparentCutSegments]); const currentSegIndexSafe = Math.min(currentSegIndex, cutSegments.length - 1); - const currentCutSeg = useMemo(() => cutSegments[currentSegIndexSafe], [currentSegIndexSafe, cutSegments]); - const currentApparentCutSeg = useMemo(() => apparentCutSegments[currentSegIndexSafe], [apparentCutSegments, currentSegIndexSafe]); + const currentCutSeg = useMemo(() => { + const ret = cutSegments[currentSegIndexSafe]; + if (ret == null) throw new Error('currentCutSeg was nullish, this shouldn\'t happen'); + return ret; + }, [currentSegIndexSafe, cutSegments]); + const currentApparentCutSeg = useMemo(() => { + const ret = apparentCutSegments[currentSegIndexSafe]; + if (ret == null) throw new Error('currentApparentCutSeg was nullish, this shouldn\'t happen'); + return ret; + }, [apparentCutSegments, currentSegIndexSafe]); const selectedSegmentsRaw = useMemo(() => apparentCutSegments.filter((segment) => isSegmentSelected(segment)), [apparentCutSegments, isSegmentSelected]); @@ -134,7 +142,7 @@ export default ({ const detectSceneChanges = useCallback(async () => { const filterOptions = await showParametersDialog({ title: i18n.t('Enter parameters'), parameters: ffmpegParameters.sceneChange() }); if (filterOptions == null) return; - await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async () => ffmpegDetectSceneChanges({ filePath, minChange: filterOptions.minChange, onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); + await detectSegments({ name: 'sceneChanges', workingText: i18n.t('Detecting scene changes'), errorText: i18n.t('Failed to detect scene changes'), fn: async () => ffmpegDetectSceneChanges({ filePath, minChange: filterOptions['minChange'], onProgress: setCutProgress, from: currentApparentCutSeg.start, to: currentApparentCutSeg.end }) }); }, [currentApparentCutSeg.end, currentApparentCutSeg.start, detectSegments, filePath, setCutProgress]); const createSegmentsFromKeyframes = useCallback(async () => { @@ -146,7 +154,7 @@ export default ({ const removeSegments = useCallback((removeSegmentIds) => { setCutSegments((existingSegments) => { - if (existingSegments.length === 1 && existingSegments[0].start == null && existingSegments[0].end == null) { + if (existingSegments.length === 1 && existingSegments[0]!.start == null && existingSegments[0]!.end == null) { return existingSegments; // We are at initial segment, nothing more we can do (it cannot be removed) } @@ -161,7 +169,7 @@ export default ({ }, [clearSegCounter, createInitialCutSegments, setCutSegments]); const removeCutSegment = useCallback((index) => { - removeSegments([cutSegments[index].segId]); + removeSegments([cutSegments[index]!.segId]); }, [cutSegments, removeSegments]); const inverseCutSegments = useMemo(() => { @@ -217,6 +225,7 @@ export default ({ }, [currentSegIndexSafe, getSegApparentEnd, currentCutSeg, duration, updateSegAtIndex]); const modifySelectedSegmentTimes = useCallback(async (transformSegment, concurrency = 5) => { + if (duration == null) throw new Error(); const clampValue = (val) => Math.min(Math.max(val, 0), duration); let newSegments = await pMap(apparentCutSegments, async (segment) => { @@ -268,7 +277,7 @@ export default ({ } catch (err) { handleError(err); } finally { - setWorking(); + setWorking(undefined); } }, [filePath, videoStream, modifySelectedSegmentTimes, setWorking, workingRef]); @@ -276,6 +285,7 @@ export default ({ if (newOrder > cutSegments.length - 1 || newOrder < 0) return; const newSegments = [...cutSegments]; const removedSeg = newSegments.splice(index, 1)[0]; + if (removedSeg == null) throw new Error(); newSegments.splice(newOrder, 0, removedSeg); setCutSegments(newSegments); setCurrentSegIndex(newOrder); @@ -303,7 +313,7 @@ export default ({ if (keyframeAlignedStart != null) suggestedStart = keyframeAlignedStart; } */ - if (suggestedStart >= duration) return; + if (duration == null || suggestedStart >= duration) return; const cutSegmentsNew = [ ...cutSegments, @@ -378,7 +388,7 @@ export default ({ }, [checkFileOpened, getRelevantTime, setCutTime]); const onLabelSegment = useCallback(async (index) => { - const { name } = cutSegments[index]; + const { name } = cutSegments[index]!; const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength }); if (value != null) updateSegAtIndex(index, { name: value }); }, [cutSegments, updateSegAtIndex, maxLabelLength]); @@ -387,13 +397,15 @@ export default ({ const relevantTime = getRelevantTime(); const segmentsAtCursorIndexes = findSegmentsAtCursor(apparentCutSegments, relevantTime); - if (segmentsAtCursorIndexes.length === 0) { + const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; + + if (firstSegmentAtCursorIndex == null) { errorToast(i18n.t('No segment to split. Please move cursor over the segment you want to split')); return; } - const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; const segment = cutSegments[firstSegmentAtCursorIndex]; + if (segment == null) throw new Error(); const getNewName = (oldName, suffix) => oldName && `${segment.name} ${suffix}`; @@ -450,7 +462,7 @@ export default ({ const onLabelSelectedSegments = useCallback(async () => { if (selectedSegmentsRaw.length === 0) return; - const { name } = selectedSegmentsRaw[0]; + const { name } = selectedSegmentsRaw[0]!; const value = await labelSegmentDialog({ currentName: name, maxLength: maxLabelLength }); if (value == null) return; setCutSegments((existingSegments) => existingSegments.map((existingSegment) => { diff --git a/src/segments.ts b/src/segments.ts index 58cb5574..453a1f71 100644 --- a/src/segments.ts +++ b/src/segments.ts @@ -1,8 +1,8 @@ -import { v4 as uuidv4 } from 'uuid'; +import { nanoid } from 'nanoid'; import sortBy from 'lodash/sortBy'; import minBy from 'lodash/minBy'; import maxBy from 'lodash/maxBy'; -import { InverseSegment, SegmentBase } from './types'; +import { ApparentSegmentBase, InverseSegment, PlaybackMode, SegmentBase } from './types'; export const isDurationValid = (duration?: number): duration is number => duration != null && Number.isFinite(duration) && duration > 0; @@ -11,7 +11,7 @@ export const createSegment = (props?: { start?: number, end?: number, name?: str start: props?.start, end: props?.end, name: props?.name || '', - segId: uuidv4(), + segId: nanoid(), segColorIndex: props?.segColorIndex, // `tags` is an optional object (key-value). Values must always be string @@ -23,12 +23,12 @@ export const createSegment = (props?: { start?: number, end?: number, name?: str // Because segments could have undefined start / end // (meaning extend to start of timeline or end duration) -export function getSegApparentStart(seg) { +export function getSegApparentStart(seg: SegmentBase) { const time = seg.start; return time !== undefined ? time : 0; } -export function getSegApparentEnd(seg, duration) { +export function getSegApparentEnd(seg: SegmentBase, duration?: number) { const time = seg.end; if (time !== undefined) return time; if (isDurationValid(duration)) return duration; @@ -45,14 +45,14 @@ export const getCleanCutSegments = (cs) => cs.map((seg) => ({ export function findSegmentsAtCursor(apparentSegments, currentTime) { const indexes: number[] = []; apparentSegments.forEach((segment, index) => { - if (segment.start < currentTime && segment.end > currentTime) indexes.push(index); + if (segment.start <= currentTime && segment.end >= currentTime) indexes.push(index); }); return indexes; } export const getSegmentTags = (segment) => (segment.tags || {}); -export const sortSegments = (segments) => sortBy(segments, 'start'); +export const sortSegments = (segments: T[]) => sortBy(segments, 'start'); // https://stackoverflow.com/a/30472982/6519037 export function partitionIntoOverlappingRanges(array, getSegmentStart = (seg) => seg.start, getSegmentEnd = (seg) => seg.end) { @@ -107,17 +107,19 @@ export function combineSelectedSegments(existingSegments: const firstSegment = minBy(selectedSegments, (seg) => getSegApparentStart(seg)); const lastSegment = maxBy(selectedSegments, (seg) => getSegApparentEnd2(seg)); - return existingSegments.map((existingSegment) => { + return existingSegments.flatMap((existingSegment) => { if (existingSegment === firstSegment) { - return { + return [{ ...firstSegment, start: firstSegment.start, end: lastSegment!.end, - }; + }]; } - if (isSegmentSelected(existingSegment)) return undefined; // remove other selected segments - return existingSegment; - }).filter(Boolean); + + if (isSegmentSelected(existingSegment)) return []; // remove other selected segments + + return [existingSegment]; + }); } export function hasAnySegmentOverlap(sortedSegments) { @@ -185,7 +187,7 @@ export function convertSegmentsToChapters(sortedSegments) { return sortSegments([...sortedSegments, ...(invertedSegments ?? [])]); } -export function playOnlyCurrentSegment({ playbackMode, currentTime, playingSegment }) { +export function playOnlyCurrentSegment({ playbackMode, currentTime, playingSegment }: { playbackMode: PlaybackMode, currentTime: number, playingSegment: ApparentSegmentBase }) { switch (playbackMode) { case 'loop-segment-start-end': { const maxSec = 3; // max time each side (start/end) @@ -195,27 +197,24 @@ export function playOnlyCurrentSegment({ playbackMode, currentTime, playingSegme const endWindowStart = playingSegment.end - sec / 2; if (currentTime >= playingSegment.end) { - return { seek: playingSegment.start }; + return { seekTo: playingSegment.start }; } if (currentTime < endWindowStart && currentTime >= startWindowEnd) { - return { seek: endWindowStart }; + return { seekTo: endWindowStart }; } break; } case 'loop-segment': { if (currentTime >= playingSegment.end) { - return { seek: playingSegment.start }; + return { seekTo: playingSegment.start }; } break; } case 'play-segment-once': { if (currentTime >= playingSegment.end) { - return { - seek: playingSegment.end, - stop: true, - }; + return { seekTo: playingSegment.end, exit: true }; } break; } diff --git a/src/types.ts b/src/types.ts index 296b671c..8d7ac0e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,19 @@ export interface SegmentBase { - start?: number, - end?: number, + start?: number | undefined, + end?: number | undefined, +} + +export interface ApparentSegmentBase { + start: number, + end: number, +} + + +export interface StateSegment extends SegmentBase { + name: string; + segId: string; + segColorIndex?: number | undefined; + tags?: Record | undefined; } export interface Segment extends SegmentBase { @@ -11,6 +24,8 @@ export interface InverseSegment extends SegmentBase { segId?: string, } +export type PlaybackMode = 'loop-segment-start-end' | 'loop-segment' | 'play-segment-once' | 'loop-selected-segments'; + export type Html5ifyMode = 'fastest' | 'fast-audio-remux' | 'fast-audio' | 'fast' | 'slow' | 'slow-audio' | 'slowest'; export type EdlFileType = 'csv' | 'csv-frames' | 'xmeml' | 'fcpxml' | 'dv-analyzer-summary-txt' | 'cue' | 'pbf' | 'mplayer' | 'srt' | 'llc'; diff --git a/yarn.lock b/yarn.lock index 9ccc9d25..e4d99cca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7747,6 +7747,7 @@ __metadata: mkdirp: "npm:^1.0.3" morgan: "npm:^1.10.0" mousetrap: "npm:^1.6.5" + nanoid: "npm:^5.0.6" p-map: "npm:^5.5.0" p-retry: "npm:^6.2.0" pify: "npm:^5.0.0" @@ -7772,7 +7773,6 @@ __metadata: typescript-plugin-css-modules: "npm:^5.1.0" use-debounce: "npm:^5.1.0" use-trace-update: "npm:^1.3.0" - uuid: "npm:^8.3.2" vite: "npm:^4.5.2" vitest: "npm:^1.2.2" wait-on: "npm:^7.0.1" @@ -8330,6 +8330,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^5.0.6": + version: 5.0.6 + resolution: "nanoid@npm:5.0.6" + bin: + nanoid: bin/nanoid.js + checksum: cd5d3eebd3b148b68b4b0238d94b1d8b4d955cc1a74b8e5217c1daecaed584d4b3701f41ce0f5e909ba4cd214592aff41fb53ac1955d77ea85d58df936726f29 + languageName: node + linkType: hard + "napi-build-utils@npm:^1.0.1": version: 1.0.2 resolution: "napi-build-utils@npm:1.0.2"