Skip to content

Commit

Permalink
Improve mouse wheel handling
Browse files Browse the repository at this point in the history
  • Loading branch information
glenne committed Oct 26, 2024
1 parent 974a5e7 commit cc78354
Show file tree
Hide file tree
Showing 13 changed files with 359 additions and 96 deletions.
29 changes: 29 additions & 0 deletions native/ffreader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@ The package name cannot have dashes like most npm packages. This is because the

If using yarn to add this module locally, you must use yarn link or the .erb/scripts/check-native-dep.js script fails running `npm ls <modulename>`. Using yarn add file:../../native/ffreader from the release/app of an electron app also works.

## Package Size

When the final app gets packaged, the .gitignore and .npmignore files tell the packager what files to leave out of the native module. The .gitignore and .npmignore need to be in the native library folder and not in a parent.

When building the Electron app that utilizes this package files to exclude are added to the top level package.json file with ! prefix:

```json
"build": {
"files": [
"dist",
"node_modules",
"package.json",
"!node_modules/**/*.cpp",
"!node_modules/**/*.h",
"!node_modules/**/*.md",
"!node_modules/**/lib-build/**/*",
"!node_modules/**/ffmpeg*/**/*"
],
}
```

## Building static opencv

To avoid linking issues when deploying to other machines than the build machine opencv is built as a static library and linked directly to the native add-on.

```bash
yarn opencv-build
```

## Building ffmpeg on Windows

Native modules must be build with MSVC toolchains.
Expand Down
2 changes: 1 addition & 1 deletion native/ffreader/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "crewtimer_video_reader",
"version": "1.0.10",
"version": "1.0.9",
"description": "A node electron utility to read mp4 files for CrewTimer Video Review",
"main": "index.js",
"types": "index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion release/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "crewtimer-video-review",
"version": "1.0.10",
"version": "1.0.11",
"description": "Review finish line video and post timing data to CrewTimer.",
"license": "MIT",
"author": {
Expand Down
4 changes: 2 additions & 2 deletions release/app/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,8 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==

crewtimer_video_reader@../../native/ffreader:
version "1.0.8"
crewtimer_video_reader@../../native/ffreader/:
version "1.0.9"
dependencies:
bindings "^1.5.0"
prebuild-install "^7.1.1"
Expand Down
4 changes: 3 additions & 1 deletion src/main/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { notifyChange } from '../../renderer/util/Util';
import { getMainWindow } from '../mainWindow';

/** Stored data instance for on-disk storage */
const store = new Store();
const store = new Store({
name: 'ct-video-review', // this will create 'ct-video-review.json'
});

/** In-memory cache for settings */
const memCache = new Map<string, unknown>();
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/shared/AppTypes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { KeyMap } from 'crewtimer-common';

export interface AppImage {
status: string;
width: number;
Expand All @@ -12,6 +14,7 @@ export interface AppImage {
fileStartTime: number;
fileEndTime: number;
motion: { x: number; y: number; dt: number; valid: boolean };
sidecar?: KeyMap;
}

export interface Rect {
Expand Down
114 changes: 114 additions & 0 deletions src/renderer/util/MouseTracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Type representing a 2D position with x and y coordinates.
*/
type Position = {
x: number;
y: number;
};

/**
* Callback type for handling mouse velocity and position updates.
*
* @callback MouseVelocityCallback
* @param {Position} position - The current mouse position.
* @param {number} velocity - The calculated mouse velocity in pixels per second.
* @param {number} pct - Percent of the element that the mouse is over.
*/
type MouseVelocityCallback = (
position: Position,
velocity: number,
pct: number
) => void;

/**
* Creates a mouse move event handler function that calculates smoothed mouse velocity
* and updates the mouse position. The function adjusts the behavior based on velocity thresholds:
* - If velocity is less than 'velocityKnee' px/s, the position is reduced to 1/4 of the actual velocity.
* - If the position is less than expected and velocity is more than 'velocityKnee' px/s, the position gradually catches up.
*
* @param {MouseVelocityCallback} callback - A callback function to be called with updated position and velocity
* at approximately 10 times per second.
* @returns {(event: MouseEvent) => void} A mouse move event handler function.
*
* @example
* // Create a handler that logs the position and velocity
* const handleMouseMove = createMouseMoveHandler((position, velocity) => {
* console.log("Position:", position, "Velocity:", velocity);
* });
*
* // Attach the handler to an element's onMouseMove event in a React component:
* // <div onMouseMove={handleMouseMove}></div>
*/
export function createMouseMoveHandler(callback: MouseVelocityCallback) {
let lastPosition: Position | null = null;
let lastTimestamp: number | null = null;
let smoothedVelocity = 0;
let smoothedX = 0;
const velocityKnee = 20; // velocity threshold for ramping up or down
const alpha = 0.5; // smoothing factor for exponential average
const xAlpha = 0.33; // smoothing factor for exponential average

/**
* Updates the position based on the calculated velocity, adjusting the behavior if necessary.
*
* @param {Position} newPosition - The new mouse position.
* @param {number} velocity - The current velocity in pixels per second.
* @param {number} pct - Percent of the element that the mouse is over.
*/
const updatePosition = (
newPosition: Position,
velocity: number,
pct: number
) => {
if (velocity < velocityKnee) {
// Slow down the position calculation if velocity is below velocityKnee px/s
newPosition.x += (newPosition.x - lastPosition!.x) * 0.25;
} else if (lastPosition && velocity > velocityKnee) {
// Adjust position based on velocity, ramping up if necessary
const predictedX = lastPosition.x + smoothedVelocity / 10;

if (newPosition.x < predictedX)
newPosition.x += (predictedX - newPosition.x) * 0.25;
}

smoothedX = xAlpha * newPosition.x + (1 - xAlpha) * smoothedX;
newPosition.x = smoothedX;
callback(newPosition, smoothedVelocity, pct);
};

/**
* Handles the mouse move event, calculating the velocity and position adjustments, then calls the callback.
*
* @param {MouseEvent} event - The mouse move event containing the current mouse position.
*/
return (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const currentTime = Date.now();
const currentPosition = { x: event.clientX, y: event.clientY };
let pct = 0.5;
if (event.target instanceof Element) {
const elementLeft = event.target.getBoundingClientRect().left;
currentPosition.x -= elementLeft;
pct = Math.min(
100,
Math.max(0, currentPosition.x / (event.target.clientWidth - 1))
);
}

if (lastPosition && lastTimestamp) {
const timeElapsed = (currentTime - lastTimestamp) / 1000; // convert to seconds
if (timeElapsed == 0) {
return;
}
const distance = currentPosition.x - lastPosition.x;
const velocity = distance / timeElapsed;

// Exponential smoothing for velocity
smoothedVelocity = alpha * velocity + (1 - alpha) * smoothedVelocity;

updatePosition(currentPosition, smoothedVelocity, pct);
}

lastPosition = currentPosition;
lastTimestamp = currentTime;
};
}
8 changes: 8 additions & 0 deletions src/renderer/video/AddSplitUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import uuidgen from 'short-uuid';
import { setToast } from 'renderer/Toast';
import { getMobileConfig, getWaypoint } from 'renderer/util/UseSettings';

let lastAddSplit = 0;
export const performAddSplit = () => {
const videoBow = getVideoBow();
const selectedEvent = getVideoEvent();
Expand All @@ -26,6 +27,13 @@ export const performAddSplit = () => {
const activeEvent = mobileConfig?.eventList?.find(
(event) => event.EventNum === selectedEvent
);

const now = Date.now();
const deltaT = now - lastAddSplit;
if (deltaT < 500) {
return; // probable double click
}
lastAddSplit = now;
if (!mobileConfig || !activeEvent || disabled) {
setToast({
severity: 'error',
Expand Down
62 changes: 22 additions & 40 deletions src/renderer/video/Video.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Box, Typography, Stack, Tooltip } from '@mui/material';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { convertTimestampToString } from '../shared/Util';
import { useDebouncedCallback } from 'use-debounce';
import makeStyles from '@mui/styles/makeStyles';
Expand All @@ -13,7 +19,6 @@ import {
setVideoTimestamp,
useResetZoomCounter,
useImage,
useMouseWheelInverted,
useTimezoneOffset,
useTravelRightToLeft,
useVideoError,
Expand Down Expand Up @@ -213,7 +218,6 @@ const VideoImage: React.FC<{ width: number; height: number }> = ({
const [videoFile] = useVideoFile();
const holdoffChanges = useRef<boolean>(false);
const [videoError] = useVideoError();
const [wheelInverted] = useMouseWheelInverted();
const [travelRightToLeft] = useTravelRightToLeft();
const [resetZoomCount] = useResetZoomCounter();
const destSize = useRef({ width, height });
Expand All @@ -239,9 +243,6 @@ const VideoImage: React.FC<{ width: number; height: number }> = ({
const videoOverlayRef = useRef<VideoOverlayHandles>(null);
const offscreenCanvas = useRef(document.createElement('canvas'));

const wheelTime = useRef(0);
const deltaTAvg = useRef(0);

useEffect(() => {
mouseTracking.current.mouseDown = false;
srcCenter.current = { x: image.width / 2, y: image.height / 2 };
Expand Down Expand Up @@ -368,6 +369,19 @@ const VideoImage: React.FC<{ width: number; height: number }> = ({
}
}, [image]);

const videoOverlay = useMemo(
() => (
<VideoOverlay
ref={videoOverlayRef}
width={width}
height={height}
destHeight={getVideoScaling().destHeight}
destWidth={getVideoScaling().destWidth}
/>
),
[width, height]
);

const selectLane = (point: Point) => {
const laneLines = getVideoSettings()
.guides.filter((lane) => lane.dir === Dir.Horiz && lane.enabled)
Expand Down Expand Up @@ -438,6 +452,7 @@ const VideoImage: React.FC<{ width: number; height: number }> = ({
}
} else if (!isZooming()) {
const finish = getFinishLine();

applyZoom({
zoom: 5,
srcPoint: {
Expand Down Expand Up @@ -523,32 +538,6 @@ const VideoImage: React.FC<{ width: number; height: number }> = ({
[image]
);

const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (holdoffChanges.current) {
return;
}

const now = Date.now();
const deltaT = Math.min(500, now - wheelTime.current);

if (deltaT >= 500 || Math.sign(deltaT) !== Math.sign(deltaTAvg.current)) {
deltaTAvg.current = deltaT;
}
const alpha = 0.75;
deltaTAvg.current = deltaTAvg.current * alpha + deltaT * (1 - alpha);
wheelTime.current = now;
// console.log(
// `Wheel: ${event.deltaY} dt: ${deltaT}, avg: ${deltaTAvg.current}`
// );

// Use 1 for slow click rate. Use 6 for fast click rate unless zooming and then use 2.
const delta =
(wheelInverted ? -1 : 1) *
Math.sign(event.deltaY) *
(deltaTAvg.current > 200 ? 1 : isZooming() ? 2 : 6);
moveToFrame(getVideoFrameNum(), delta);
}, []);

const handleRightClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -578,7 +567,6 @@ const VideoImage: React.FC<{ width: number; height: number }> = ({
return (
<Stack direction="column">
<Box
onWheel={adjustingOverlay ? undefined : handleWheel}
onMouseDown={adjustingOverlay ? undefined : handleMouseDown}
onMouseMove={handleMouseMove} //{adjustingOverlay ? undefined : handleMouseMove}
onMouseUp={adjustingOverlay ? undefined : handleMouseUp}
Expand Down Expand Up @@ -657,13 +645,7 @@ const VideoImage: React.FC<{ width: number; height: number }> = ({
}
/>
)}
<VideoOverlay
ref={videoOverlayRef}
width={width}
height={height}
destHeight={getVideoScaling().destHeight}
destWidth={getVideoScaling().destWidth}
/>
{videoOverlay}
</Box>
</Stack>
);
Expand Down
9 changes: 6 additions & 3 deletions src/renderer/video/VideoFileUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ const doRequestVideoFrame = async ({
(1000000 * imageStart.numFrames) / imageStart.fps
);

const fileStatus = getFileStatusList().find((f) => {
return f.filename === videoFile;
});
openFileStatus = {
filename: videoFile,
open: true,
Expand All @@ -175,9 +178,8 @@ const doRequestVideoFrame = async ({
endTime: imageEndTime,
duration: imageEndTime - imageStart.tsMicro,
fps: imageStart.fps,
sidecar: {},
sidecar: fileStatus?.sidecar || {},
};
// console.log(JSON.stringify(openFileStatus, null, 2));
}

let seekPos =
Expand Down Expand Up @@ -243,6 +245,7 @@ const doRequestVideoFrame = async ({

imageStart.fileStartTime = openFileStatus.startTime / 1000;
imageStart.fileEndTime = openFileStatus.endTime / 1000;
imageStart.sidecar = openFileStatus.sidecar;
setImage(imageStart);
if (fromClick) {
// force a jump in the VideoScrubber
Expand Down Expand Up @@ -425,7 +428,7 @@ export const refreshDirList = async (videoDir: string) => {
endTime: 300,
duration: 300,
fps: 60,
sidecar: {},
sidecar: videoSidecar || {},
};
if (videoSidecar.file) {
fileStatus.numFrames = videoSidecar.file.numFrames;
Expand Down
Loading

0 comments on commit cc78354

Please sign in to comment.