improvements

- improve preview playback (allow resuming from current position) - closes #1584
- new keyboard actions: Jump to first & last segment
- more types
pull/1902/head
Mikael Finstad 2 years ago
parent 16cec4213b
commit fd1955a970
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

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

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

@ -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<FfmpegCommandLog>([]);
const [previewFilePath, setPreviewFilePath] = useState<string>();
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<PlaybackMode>();
const [playerTime, setPlayerTime] = useState<number>();
const [duration, setDuration] = useState<number>();
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<StateSegment>();
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,

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

@ -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<string, ParameterDialogParameter>;
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<HTMLInputElement>(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;

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

@ -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<boolean>, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, setCutProgress: (a: number | undefined) => void, videoStream, duration?: number, getRelevantTime: () => number, maxLabelLength: number, checkFileOpened: () => boolean, invertCutSegments: boolean, segmentsToChaptersOnly: boolean,
}) => {
// Segment related state
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) => {

@ -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 = <T>(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<T extends SegmentBase>(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;
}

@ -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<string, string> | 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';

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

Loading…
Cancel
Save