2 Commity c44cb6f2ad ... 2ecefd1e10

Autor SHA1 Wiadomość Data
  Scott Bezek 2ecefd1e10 Checkpoint frontend work 3 lat temu
  Scott Bezek eb522bb0d2 Checkpoint frontend work 3 lat temu

+ 19 - 1
software/js/package-lock.json

@@ -4051,6 +4051,12 @@
       "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
       "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
     },
+    "node_modules/@types/lodash": {
+      "version": "4.14.191",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
+      "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
+      "dev": true
+    },
     "node_modules/@types/long": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
@@ -16898,6 +16904,7 @@
         "@types/node": "^16.18.4",
         "@types/react": "^18.0.25",
         "@types/react-dom": "^18.0.9",
+        "lodash": "^4.17.21",
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
         "react-scripts": "^5.0.1",
@@ -16905,6 +16912,9 @@
         "socket.io-client": "^4.5.4",
         "typescript": "^4.9.3",
         "web-vitals": "^2.1.4"
+      },
+      "devDependencies": {
+        "@types/lodash": "^4.14.191"
       }
     },
     "packages/demo-frontend/node_modules/@types/node": {
@@ -19719,6 +19729,12 @@
       "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
       "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
     },
+    "@types/lodash": {
+      "version": "4.14.191",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
+      "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
+      "dev": true
+    },
     "@types/long": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
@@ -21780,13 +21796,15 @@
         "@testing-library/react": "^13.4.0",
         "@testing-library/user-event": "^13.5.0",
         "@types/jest": "^27.5.2",
+        "@types/lodash": "*",
         "@types/node": "^16.18.4",
         "@types/react": "^18.0.25",
         "@types/react-dom": "^18.0.9",
+        "lodash": "^4.17.21",
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
         "react-scripts": "^5.0.1",
-        "smartknobjs-proto": "^0.1.0",
+        "smartknobjs-proto": "^0.1.1",
         "socket.io-client": "^4.5.4",
         "typescript": "^4.9.3",
         "web-vitals": "^2.1.4"

+ 5 - 1
software/js/packages/demo-frontend/package.json

@@ -14,6 +14,7 @@
         "@types/node": "^16.18.4",
         "@types/react": "^18.0.25",
         "@types/react-dom": "^18.0.9",
+        "lodash": "^4.17.21",
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
         "react-scripts": "^5.0.1",
@@ -46,5 +47,8 @@
             "last 1 safari version"
         ]
     },
-    "proxy": "http://localhost:3001"
+    "proxy": "http://localhost:3001",
+    "devDependencies": {
+        "@types/lodash": "^4.14.191"
+    }
 }

+ 214 - 101
software/js/packages/demo-frontend/src/App.tsx

@@ -1,120 +1,233 @@
-import React, { useEffect, useState } from 'react'
+import React, {useEffect, useState} from 'react'
 import './App.css'
-import io from 'socket.io-client';
-import Typography from '@mui/material/Typography';
-import Container from '@mui/material/Container';
+import io from 'socket.io-client'
+import Typography from '@mui/material/Typography'
+import Container from '@mui/material/Container'
 import ToggleButton from '@mui/material/ToggleButton'
 import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
-import { PB } from "smartknobjs-proto";
+import {PB} from 'smartknobjs-proto'
+import {VideoInfo} from './types'
+import {Card, CardActions, CardContent} from '@mui/material'
+import {exhaustiveCheck, lerp, NoUndefinedField} from './util'
 
-
-const socket = io();
+const socket = io()
 
 enum Mode {
-  Scroll = "Scroll",
-  Frames = "Frames",
-  Speed = "Speed",
+    Scroll = 'Scroll',
+    Frames = 'Frames',
+    Speed = 'Speed',
+}
+
+type State = {
+    mode: Mode
+    playbackSpeed: number
+    currentFrame: number
 }
 
-const CONFIG_BY_MODE: Record<Mode, PB.SmartKnobConfig> = {
-  [Mode.Scroll]: PB.SmartKnobConfig.create({
-    numPositions: 32,
-    position: 0,
-    positionWidthRadians: 7*Math.PI / 180,
-    detentStrengthUnit: 2.5,
-    endstopStrengthUnit: 1,
-    snapPoint: 0.7,
-    text: "Scroll",
-    detentPositions: [2, 10, 21, 22],
-    snapPointBias: 0,
-    snapPointBiasCenterPosition: 0,
-  }),
-  [Mode.Frames]: PB.SmartKnobConfig.create({
-    numPositions: 256,
-    position: 127,
-    positionWidthRadians: 1*Math.PI / 180,
-    detentStrengthUnit: 1,
-    endstopStrengthUnit: 1,
-    snapPoint: 1.1,
-    text: "Frames",
-    detentPositions: [],
-    snapPointBias: 0,
-    snapPointBiasCenterPosition: 0,
-  }),
-  [Mode.Speed]: PB.SmartKnobConfig.create({
-    numPositions: 13,
-    position: 6,
-    positionWidthRadians: 60 * Math.PI / 180,
-    detentStrengthUnit: 1,
-    endstopStrengthUnit: 1,
-    snapPoint: 0.55,
-    text: "Playback\nSpeed",
-    detentPositions: [],
-    snapPointBias: 0.4,
-    snapPointBiasCenterPosition: 6,
-  }),
+export type AppProps = {
+    info: VideoInfo
 }
+export const App: React.FC<AppProps> = ({info}) => {
+    const [isConnected, setIsConnected] = useState(socket.connected)
+    const [state, setState] = useState<string>('')
+
+    const [derivedState, setDerivedState] = useState<State>({
+        mode: Mode.Scroll,
+        playbackSpeed: 0,
+        currentFrame: 0,
+    })
 
-function App() {
-  const [isConnected, setIsConnected] = useState(socket.connected);
-  const [state, setState] = useState<string>("");
+    const pushConfig = (state: State) => {
+        let config: PB.SmartKnobConfig
+        if (state.mode === Mode.Scroll) {
+            // TODO
+            config = PB.SmartKnobConfig.create({
+                numPositions: 32,
+                position: 0,
+                positionWidthRadians: (7 * Math.PI) / 180,
+                detentStrengthUnit: 2.5,
+                endstopStrengthUnit: 1,
+                snapPoint: 0.7,
+                text: Mode.Scroll,
+                detentPositions: [0, 10, 21, 22],
+                snapPointBias: 0,
+                snapPointBiasCenterPosition: 0,
+            })
+        } else if (state.mode === Mode.Frames) {
+            config = PB.SmartKnobConfig.create({
+                numPositions: info.totalFrames,
+                position: state.currentFrame,
+                positionWidthRadians: (1 * Math.PI) / 180,
+                detentStrengthUnit: 1,
+                endstopStrengthUnit: 1,
+                snapPoint: 1.1,
+                text: Mode.Frames,
+                detentPositions: [],
+                snapPointBias: 0,
+                snapPointBiasCenterPosition: 0,
+            })
+        } else if (state.mode === Mode.Speed) {
+            config = PB.SmartKnobConfig.create({
+                numPositions: 13,
+                position: 6 + Math.trunc(state.playbackSpeed),
+                positionWidthRadians: (60 * Math.PI) / 180,
+                detentStrengthUnit: 1,
+                endstopStrengthUnit: 1,
+                snapPoint: 0.55,
+                text: Mode.Speed,
+                detentPositions: [],
+                snapPointBias: 0.4,
+                snapPointBiasCenterPosition: 6,
+            })
+        } else {
+            throw exhaustiveCheck(state.mode)
+        }
+        socket.emit('set_config', config)
+    }
 
-  const [mode, setMode] = useState<Mode>(Mode.Scroll);
+    const setCurrentFrame = (fn: (oldFrame: number) => number) => {
+        setDerivedState((cur) => {
+            const newState = {...cur}
+            if (cur.mode === Mode.Speed) {
+                newState.currentFrame = fn(cur.currentFrame)
+            }
+            return newState
+        })
+    }
 
-  useEffect(() => {
-    socket.on('connect', () => {
-      setIsConnected(true);
-    });
+    useEffect(() => {
+        const fps = info.frameRate * derivedState.playbackSpeed
+        const msPerFrame = 1000 / fps
+        if (derivedState.mode === Mode.Speed && fps !== 0) {
+            const timer = setInterval(() => {
+                setCurrentFrame((oldFrame) => {
+                    const newFrame = oldFrame + Math.sign(derivedState.playbackSpeed)
+                    if (newFrame < 0 || newFrame >= info.totalFrames) {
+                        pushConfig({
+                            mode: derivedState.mode,
+                            playbackSpeed: 0,
+                            currentFrame: -1, // unused
+                        })
+                        return oldFrame
+                    } else {
+                        return newFrame
+                    }
+                })
+            }, msPerFrame)
+            return () => clearInterval(timer)
+        }
+    }, [derivedState.mode, derivedState.playbackSpeed, info.totalFrames, info.frameRate])
 
-    socket.on('disconnect', () => {
-      setIsConnected(false);
-    });
+    useEffect(() => {
+        socket.on('connect', () => {
+            setIsConnected(true)
+        })
 
-    socket.on('state', (input: {pb: PB.SmartKnobState}) => {
-      const {pb: state} = input
-      const stateObj = PB.SmartKnobState.toObject(
-        state,
-        { defaults: true }
-      );
-      setState(JSON.stringify(stateObj, undefined, 4));
-    });
+        socket.on('disconnect', () => {
+            setIsConnected(false)
+        })
 
-    return () => {
-      socket.off('connect');
-      socket.off('disconnect');
-      socket.off('state');
-    };
-  }, []);
-    return (
-      <Container component="main" maxWidth="xs">
-        <Typography component="h1" variant="h5">
-          Video Playback Control Demo
-        </Typography>
-        <ToggleButtonGroup
-          color="primary"
-          value={mode}
-          exclusive
-          onChange={(e, value: Mode) => {
-            setMode(value)
-            socket.emit("set_config", CONFIG_BY_MODE[value])
-          }}
-          aria-label="Mode"
-        >
-          {
-            Object.keys(Mode).map((mode) => (
-              <ToggleButton value={mode} key={mode}>{mode}</ToggleButton>
-            ))
-            
-          }
-        </ToggleButtonGroup>
-        <div>
-        {
-          isConnected ? (
-            <code>{state}</code>
-          ) : <p>Not connected</p>
+        socket.on('state', (input: {pb: PB.SmartKnobState}) => {
+            const {pb: state} = input
+            const stateObj = PB.SmartKnobState.toObject(state, {
+                defaults: true,
+            }) as NoUndefinedField<PB.ISmartKnobState>
+            setState(JSON.stringify(stateObj, undefined, 4))
+            setDerivedState((cur) => {
+                const modeText = stateObj.config.text
+                if (modeText === Mode.Scroll) {
+                    return {
+                        mode: Mode.Scroll,
+                        playbackSpeed: 0,
+                        currentFrame: cur.currentFrame, // TODO - map from state
+                    }
+                } else if (modeText === Mode.Frames) {
+                    return {
+                        mode: Mode.Frames,
+                        playbackSpeed: 0,
+                        currentFrame: stateObj.currentPosition ?? 0,
+                    }
+                } else if (modeText === Mode.Speed) {
+                    const normalizedWholeValue = stateObj.currentPosition - stateObj.config.snapPointBiasCenterPosition
+                    const normalizedFractional =
+                        Math.sign(stateObj.subPositionUnit) *
+                        lerp(stateObj.subPositionUnit * Math.sign(stateObj.subPositionUnit), 0.1, 0.9, 0, 1)
+                    const normalized = normalizedWholeValue + normalizedFractional
+                    const speed = Math.sign(normalized) * Math.pow(2, Math.abs(normalized) - 1)
+                    return {
+                        mode: Mode.Speed,
+                        playbackSpeed: speed,
+                        currentFrame: cur.currentFrame,
+                    }
+                }
+                return cur
+            })
+        })
+        return () => {
+            socket.off('connect')
+            socket.off('disconnect')
+            socket.off('state')
         }
-        </div>
-      </Container>
+    }, [])
+    return (
+        <Container component="main" maxWidth="sm">
+            <Card>
+                <CardContent>
+                    <Typography component="h1" variant="h5">
+                        Video Playback Control Demo
+                    </Typography>
+                    <ToggleButtonGroup
+                        color="primary"
+                        value={derivedState.mode}
+                        exclusive
+                        onChange={(e, value: Mode | null) => {
+                            if (value === null) {
+                                return
+                            }
+                            pushConfig({
+                                mode: value,
+                                currentFrame: derivedState.currentFrame,
+                                playbackSpeed: derivedState.playbackSpeed,
+                            })
+                        }}
+                        aria-label="Mode"
+                    >
+                        {Object.keys(Mode).map((mode) => (
+                            <ToggleButton value={mode} key={mode}>
+                                {mode}
+                            </ToggleButton>
+                        ))}
+                    </ToggleButtonGroup>
+                    <Typography component="h1" variant="h5">
+                        Frame {derivedState.currentFrame} / {info.totalFrames - 1}
+                        <br />
+                        Speed {derivedState.playbackSpeed}
+                    </Typography>
+                </CardContent>
+                <CardActions>
+                    {/* {derivedState.mode === Mode.Speed && (
+                        <Button
+                            size="small"
+                            onClick={() => {
+                                if (derivedState.playbackSpeed !== 0) {
+                                    setPlaybackSpeed(0)
+                                } else {
+                                    setPlaybackSpeed(1)
+                                }
+                                pushCurrentConfig()
+                            }}
+                        >
+                            Play
+                        </Button>
+                    )} */}
+                </CardActions>
+            </Card>
+            <Card>
+                <CardContent>
+                    <div>{isConnected ? <code>{state}</code> : <p>Not connected</p>}</div>
+                </CardContent>
+            </Card>
+        </Container>
     )
 }
 

+ 27 - 23
software/js/packages/demo-frontend/src/index.tsx

@@ -1,31 +1,35 @@
-import React from 'react';
-import ReactDOM from 'react-dom/client';
-import './index.css';
-import App from './App';
-import reportWebVitals from './reportWebVitals';
-import '@fontsource/roboto/300.css';
-import '@fontsource/roboto/400.css';
-import '@fontsource/roboto/500.css';
-import '@fontsource/roboto/700.css';
-import CssBaseline from '@mui/material/CssBaseline';
-import { createTheme, ThemeProvider } from '@mui/material/styles';
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import './index.css'
+import App from './App'
+import reportWebVitals from './reportWebVitals'
+import '@fontsource/roboto/300.css'
+import '@fontsource/roboto/400.css'
+import '@fontsource/roboto/500.css'
+import '@fontsource/roboto/700.css'
+import CssBaseline from '@mui/material/CssBaseline'
+import {createTheme, ThemeProvider} from '@mui/material/styles'
+import {VideoInfo} from './types'
 
+const theme = createTheme()
 
-const theme = createTheme();
+const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
 
-const root = ReactDOM.createRoot(
-  document.getElementById('root') as HTMLElement
-);
+const info: VideoInfo = {
+    totalFrames: 30 * 60 * 5,
+    frameRate: 30,
+    boundaryFrames: [1234, 1290, 3000],
+}
 root.render(
-  <React.StrictMode>
-    <ThemeProvider theme={theme}>
-      <CssBaseline />
-      <App />
-    </ThemeProvider>
-  </React.StrictMode>
-);
+    <React.StrictMode>
+        <ThemeProvider theme={theme}>
+            <CssBaseline />
+            <App info={info} />
+        </ThemeProvider>
+    </React.StrictMode>,
+)
 
 // If you want to start measuring performance in your app, pass a function
 // to log results (for example: reportWebVitals(console.log))
 // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
-reportWebVitals();
+reportWebVitals()

+ 5 - 0
software/js/packages/demo-frontend/src/types.tsx

@@ -0,0 +1,5 @@
+export type VideoInfo = {
+    totalFrames: number
+    frameRate: number
+    boundaryFrames: number[]
+}

+ 19 - 0
software/js/packages/demo-frontend/src/util.ts

@@ -0,0 +1,19 @@
+export const exhaustiveCheck = (x: never): never => {
+    throw new Error("Didn't expect to get here", x)
+}
+
+export const isSome = <T>(v: T | null | undefined): v is T => {
+    return v !== null && v !== undefined
+}
+
+export const lerp = (value: number, inMin: number, inMax: number, min: number, max: number): number => {
+    // Map the input value from the input range to the output range
+    value = ((value - inMin) / (inMax - inMin)) * (max - min) + min
+
+    // Clamp the mapped value between the minimum and maximum range
+    return Math.min(Math.max(value, min), max)
+}
+
+export type NoUndefinedField<T> = {
+    [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>>
+}