|
|
@@ -1,4 +1,4 @@
|
|
|
-import React, {useEffect, useState} from 'react'
|
|
|
+import React, {useEffect, useMemo, useRef, useState} from 'react'
|
|
|
import './App.css'
|
|
|
import io from 'socket.io-client'
|
|
|
import Typography from '@mui/material/Typography'
|
|
|
@@ -7,13 +7,16 @@ import ToggleButton from '@mui/material/ToggleButton'
|
|
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
|
|
import {PB} from 'smartknobjs-proto'
|
|
|
import {VideoInfo} from './types'
|
|
|
-import {Card, CardActions, CardContent} from '@mui/material'
|
|
|
-import {exhaustiveCheck, INT32_MIN, lerp, NoUndefinedField} from './util'
|
|
|
+import {Card, CardContent} from '@mui/material'
|
|
|
+import {exhaustiveCheck, findNClosest, INT32_MIN, lerp, NoUndefinedField} from './util'
|
|
|
+import {groupBy, parseInt} from 'lodash'
|
|
|
|
|
|
const socket = io()
|
|
|
|
|
|
-// const MIN_ZOOM = 0.01
|
|
|
-// const MAX_ZOOM = 40
|
|
|
+const MIN_ZOOM = 0.01
|
|
|
+const MAX_ZOOM = 60
|
|
|
+
|
|
|
+const PIXELS_PER_POSITION = 10
|
|
|
|
|
|
enum Mode {
|
|
|
Scroll = 'Scroll',
|
|
|
@@ -33,8 +36,11 @@ export type AppProps = {
|
|
|
}
|
|
|
export const App: React.FC<AppProps> = ({info}) => {
|
|
|
const [isConnected, setIsConnected] = useState(socket.connected)
|
|
|
- const [state, setState] = useState<string>('')
|
|
|
-
|
|
|
+ const [state, setState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
|
|
|
+ PB.SmartKnobState.toObject(PB.SmartKnobState.create({config: PB.SmartKnobConfig.create()}), {
|
|
|
+ defaults: true,
|
|
|
+ }) as NoUndefinedField<PB.ISmartKnobState>,
|
|
|
+ )
|
|
|
const [derivedState, setDerivedState] = useState<State>({
|
|
|
mode: Mode.Scroll,
|
|
|
playbackSpeed: 0,
|
|
|
@@ -42,20 +48,91 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
zoomTimelinePixelsPerFrame: 0.1,
|
|
|
})
|
|
|
|
|
|
+ useMemo(() => {
|
|
|
+ setDerivedState((cur) => {
|
|
|
+ const modeText = state.config.text
|
|
|
+ if (modeText === Mode.Scroll) {
|
|
|
+ const rawFrame = Math.trunc(
|
|
|
+ ((state.currentPosition + state.subPositionUnit) * PIXELS_PER_POSITION) /
|
|
|
+ cur.zoomTimelinePixelsPerFrame,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ mode: Mode.Scroll,
|
|
|
+ playbackSpeed: 0,
|
|
|
+ currentFrame: Math.min(Math.max(rawFrame, 0), info.totalFrames - 1),
|
|
|
+ zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
|
|
|
+ }
|
|
|
+ } else if (modeText === Mode.Frames) {
|
|
|
+ return {
|
|
|
+ mode: Mode.Frames,
|
|
|
+ playbackSpeed: 0,
|
|
|
+ currentFrame: state.currentPosition ?? 0,
|
|
|
+ zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
|
|
|
+ }
|
|
|
+ } else if (modeText === Mode.Speed) {
|
|
|
+ const normalizedWholeValue = state.currentPosition
|
|
|
+ const normalizedFractional =
|
|
|
+ Math.sign(state.subPositionUnit) *
|
|
|
+ lerp(state.subPositionUnit * Math.sign(state.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,
|
|
|
+ zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return cur
|
|
|
+ })
|
|
|
+ }, [state.config.text, state.currentPosition, state.subPositionUnit])
|
|
|
+
|
|
|
+ const totalPositions = Math.ceil((info.totalFrames * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
|
|
|
+ const detentPositions = useMemo(() => {
|
|
|
+ // Always include the first and last positions at detents
|
|
|
+ const positionsToFrames = groupBy([0, ...info.boundaryFrames, info.totalFrames - 1], (frame) =>
|
|
|
+ Math.round((frame * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION),
|
|
|
+ )
|
|
|
+ console.log(JSON.stringify(positionsToFrames))
|
|
|
+ return positionsToFrames
|
|
|
+ }, [info.boundaryFrames, totalPositions, derivedState.zoomTimelinePixelsPerFrame])
|
|
|
+
|
|
|
+ // Continuous config updates for scrolling, to update detent positions
|
|
|
+ useMemo(() => {
|
|
|
+ if (derivedState.mode === Mode.Scroll) {
|
|
|
+ const config = PB.SmartKnobConfig.create({
|
|
|
+ position: INT32_MIN,
|
|
|
+ minPosition: 0,
|
|
|
+ maxPosition: totalPositions - 1,
|
|
|
+ positionWidthRadians: (8 * Math.PI) / 180,
|
|
|
+ detentStrengthUnit: 2.5,
|
|
|
+ endstopStrengthUnit: 1,
|
|
|
+ snapPoint: 0.7,
|
|
|
+ text: Mode.Scroll,
|
|
|
+ detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), state.currentPosition, 5),
|
|
|
+ snapPointBias: 0,
|
|
|
+ })
|
|
|
+ socket.emit('set_config', config)
|
|
|
+ }
|
|
|
+ }, [derivedState.mode, derivedState.zoomTimelinePixelsPerFrame, detentPositions, state.currentPosition])
|
|
|
+
|
|
|
+ // For one-off config pushes, e.g. mode changes
|
|
|
const pushConfig = (state: State) => {
|
|
|
let config: PB.SmartKnobConfig
|
|
|
if (state.mode === Mode.Scroll) {
|
|
|
- // TODO
|
|
|
+ const position = Math.trunc((state.currentFrame * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
|
|
|
config = PB.SmartKnobConfig.create({
|
|
|
- position: 0,
|
|
|
+ position,
|
|
|
minPosition: 0,
|
|
|
- maxPosition: 31,
|
|
|
- positionWidthRadians: (7 * Math.PI) / 180,
|
|
|
+ maxPosition: Math.trunc(
|
|
|
+ ((info.totalFrames - 1) * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
|
|
|
+ ),
|
|
|
+ positionWidthRadians: (8 * Math.PI) / 180,
|
|
|
detentStrengthUnit: 2.5,
|
|
|
endstopStrengthUnit: 1,
|
|
|
snapPoint: 0.7,
|
|
|
text: Mode.Scroll,
|
|
|
- detentPositions: [0, 10, 21, 22],
|
|
|
+ detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), position, 5),
|
|
|
snapPointBias: 0,
|
|
|
})
|
|
|
} else if (state.mode === Mode.Frames) {
|
|
|
@@ -63,7 +140,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
position: state.currentFrame,
|
|
|
minPosition: 0,
|
|
|
maxPosition: info.totalFrames - 1,
|
|
|
- positionWidthRadians: (1 * Math.PI) / 180,
|
|
|
+ positionWidthRadians: (1.5 * Math.PI) / 180,
|
|
|
detentStrengthUnit: 1,
|
|
|
endstopStrengthUnit: 1,
|
|
|
snapPoint: 1.1,
|
|
|
@@ -100,6 +177,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
+ // Timer for speed-based playback
|
|
|
useEffect(() => {
|
|
|
const refreshInterval = 20
|
|
|
const fps = info.frameRate * derivedState.playbackSpeed
|
|
|
@@ -113,13 +191,15 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
|
|
|
if (newFrame < 0 || newFrame >= info.totalFrames) {
|
|
|
const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
|
|
|
- // TODO: figure out how to avoid sending this config repeatedly
|
|
|
- pushConfig({
|
|
|
- mode: Mode.Speed,
|
|
|
- playbackSpeed: 0,
|
|
|
- currentFrame: Math.trunc(clampedNewFrame),
|
|
|
- zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
|
|
|
- })
|
|
|
+ if (oldFrame !== clampedNewFrame) {
|
|
|
+ // If we've hit a boundary, push a config to set the bounds
|
|
|
+ pushConfig({
|
|
|
+ mode: Mode.Speed,
|
|
|
+ playbackSpeed: 0,
|
|
|
+ currentFrame: Math.trunc(clampedNewFrame),
|
|
|
+ zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
|
|
|
+ })
|
|
|
+ }
|
|
|
return clampedNewFrame
|
|
|
} else {
|
|
|
if (
|
|
|
@@ -142,6 +222,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
}
|
|
|
}, [derivedState.mode, derivedState.playbackSpeed, info.totalFrames, info.frameRate])
|
|
|
|
|
|
+ // Socket.io subscription
|
|
|
useEffect(() => {
|
|
|
socket.on('connect', () => {
|
|
|
setIsConnected(true)
|
|
|
@@ -157,39 +238,7 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
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 and zoom
|
|
|
- zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
|
|
|
- }
|
|
|
- } else if (modeText === Mode.Frames) {
|
|
|
- return {
|
|
|
- mode: Mode.Frames,
|
|
|
- playbackSpeed: 0,
|
|
|
- currentFrame: stateObj.currentPosition ?? 0,
|
|
|
- zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
|
|
|
- }
|
|
|
- } else if (modeText === Mode.Speed) {
|
|
|
- const normalizedWholeValue = stateObj.currentPosition
|
|
|
- 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,
|
|
|
- zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
|
|
|
- }
|
|
|
- }
|
|
|
- return cur
|
|
|
- })
|
|
|
+ setState(stateObj)
|
|
|
})
|
|
|
return () => {
|
|
|
socket.off('connect')
|
|
|
@@ -198,64 +247,162 @@ export const App: React.FC<AppProps> = ({info}) => {
|
|
|
}
|
|
|
}, [])
|
|
|
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({
|
|
|
- ...derivedState,
|
|
|
- mode: value,
|
|
|
- })
|
|
|
- }}
|
|
|
- aria-label="Mode"
|
|
|
- >
|
|
|
- {Object.keys(Mode).map((mode) => (
|
|
|
- <ToggleButton value={mode} key={mode}>
|
|
|
- {mode}
|
|
|
- </ToggleButton>
|
|
|
- ))}
|
|
|
- </ToggleButtonGroup>
|
|
|
- <Typography component="h1" variant="h5">
|
|
|
- Frame {Math.trunc(derivedState.currentFrame)} / {info.totalFrames - 1}
|
|
|
- <br />
|
|
|
- Speed {Math.trunc(derivedState.playbackSpeed * 10) / 10}
|
|
|
- </Typography>
|
|
|
- </CardContent>
|
|
|
- <CardActions>
|
|
|
- {/* {derivedState.mode === Mode.Speed && (
|
|
|
- <Button
|
|
|
- size="small"
|
|
|
- onClick={() => {
|
|
|
- if (derivedState.playbackSpeed !== 0) {
|
|
|
- setPlaybackSpeed(0)
|
|
|
- } else {
|
|
|
- setPlaybackSpeed(1)
|
|
|
+ <>
|
|
|
+ <Container component="main" maxWidth="md">
|
|
|
+ <Card>
|
|
|
+ <CardContent>
|
|
|
+ <Typography component="h1" variant="h5">
|
|
|
+ Video Playback Control Demo
|
|
|
+ </Typography>
|
|
|
+ {isConnected || (
|
|
|
+ <Typography component="h6" variant="h6">
|
|
|
+ [Not connected]
|
|
|
+ </Typography>
|
|
|
+ )}
|
|
|
+ <ToggleButtonGroup
|
|
|
+ color="primary"
|
|
|
+ value={derivedState.mode}
|
|
|
+ exclusive
|
|
|
+ onChange={(e, value: Mode | null) => {
|
|
|
+ if (value === null) {
|
|
|
+ return
|
|
|
}
|
|
|
- pushCurrentConfig()
|
|
|
+ pushConfig({
|
|
|
+ ...derivedState,
|
|
|
+ mode: value,
|
|
|
+ })
|
|
|
}}
|
|
|
+ aria-label="Mode"
|
|
|
>
|
|
|
- Play
|
|
|
- </Button>
|
|
|
- )} */}
|
|
|
- </CardActions>
|
|
|
- </Card>
|
|
|
- <Card>
|
|
|
- <CardContent>
|
|
|
- <div>{isConnected ? <code>{state}</code> : <p>Not connected</p>}</div>
|
|
|
- </CardContent>
|
|
|
- </Card>
|
|
|
- </Container>
|
|
|
+ {Object.keys(Mode).map((mode) => (
|
|
|
+ <ToggleButton value={mode} key={mode}>
|
|
|
+ {mode}
|
|
|
+ </ToggleButton>
|
|
|
+ ))}
|
|
|
+ </ToggleButtonGroup>
|
|
|
+ <Typography>
|
|
|
+ Frame {Math.trunc(derivedState.currentFrame)} / {info.totalFrames - 1}
|
|
|
+ <br />
|
|
|
+ Speed {Math.trunc(derivedState.playbackSpeed * 10) / 10}
|
|
|
+ </Typography>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Timeline
|
|
|
+ info={info}
|
|
|
+ currentFrame={derivedState.currentFrame}
|
|
|
+ zoomTimelinePixelsPerFrame={derivedState.zoomTimelinePixelsPerFrame}
|
|
|
+ adjustZoom={(factor) => {
|
|
|
+ setDerivedState((cur) => {
|
|
|
+ const newZoom = Math.min(
|
|
|
+ Math.max(cur.zoomTimelinePixelsPerFrame * factor, MIN_ZOOM),
|
|
|
+ MAX_ZOOM,
|
|
|
+ )
|
|
|
+ console.log(factor, newZoom)
|
|
|
+ return {
|
|
|
+ ...cur,
|
|
|
+ zoomTimelinePixelsPerFrame: newZoom,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <Card>
|
|
|
+ <CardContent>
|
|
|
+ <div>{JSON.stringify(detentPositions)}</div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </Container>
|
|
|
+ </>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-export default App
|
|
|
+export type TimelineProps = {
|
|
|
+ info: VideoInfo
|
|
|
+ currentFrame: number
|
|
|
+ zoomTimelinePixelsPerFrame: number
|
|
|
+ adjustZoom: (factor: number) => void
|
|
|
+}
|
|
|
+export const Timeline: React.FC<TimelineProps> = ({info, currentFrame, zoomTimelinePixelsPerFrame, adjustZoom}) => {
|
|
|
+ const gradients = [
|
|
|
+ 'linear-gradient( 135deg, #FDEB71 10%, #F8D800 100%)',
|
|
|
+ 'linear-gradient( 135deg, #ABDCFF 10%, #0396FF 100%)',
|
|
|
+ 'linear-gradient( 135deg, #FEB692 10%, #EA5455 100%)',
|
|
|
+ 'linear-gradient( 135deg, #CE9FFC 10%, #7367F0 100%)',
|
|
|
+ 'linear-gradient( 135deg, #90F7EC 10%, #32CCBC 100%)',
|
|
|
+ ]
|
|
|
+
|
|
|
+ const timelineRef = useRef<HTMLDivElement>(null)
|
|
|
+ const cursorRef = useRef<HTMLDivElement>(null)
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const handleWheel = (event: HTMLElementEventMap['wheel']) => {
|
|
|
+ const delta = event.deltaY
|
|
|
+ if (delta) {
|
|
|
+ event.preventDefault()
|
|
|
+ adjustZoom(1 - delta / 500)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ timelineRef.current?.addEventListener('wheel', handleWheel)
|
|
|
+ return () => {
|
|
|
+ timelineRef.current?.removeEventListener('wheel', handleWheel)
|
|
|
+ }
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ cursorRef.current?.scrollIntoView()
|
|
|
+ }, [currentFrame, zoomTimelinePixelsPerFrame])
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className="timeline-container"
|
|
|
+ ref={timelineRef}
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ margin: '10px auto',
|
|
|
+ overflowX: 'scroll',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ className="timeline"
|
|
|
+ style={{
|
|
|
+ position: 'relative',
|
|
|
+ display: 'inline-block',
|
|
|
+ height: '80px',
|
|
|
+ width: `${zoomTimelinePixelsPerFrame * info.totalFrames}px`,
|
|
|
+ backgroundColor: '#dde',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {[...info.boundaryFrames, info.totalFrames].map((f, i, a) => {
|
|
|
+ const lengthFrames = f - (a[i - 1] ?? 0)
|
|
|
+ const widthPixels = zoomTimelinePixelsPerFrame * lengthFrames
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={`clip-${f}`}
|
|
|
+ className="video-clip"
|
|
|
+ style={{
|
|
|
+ position: 'relative',
|
|
|
+ display: 'inline-block',
|
|
|
+ top: '10px',
|
|
|
+ height: '60px',
|
|
|
+ width: `${widthPixels}px`,
|
|
|
+ backgroundImage: gradients[i % gradients.length],
|
|
|
+ }}
|
|
|
+ ></div>
|
|
|
+ )
|
|
|
+ })}
|
|
|
+ <div
|
|
|
+ className="playback-cursor"
|
|
|
+ ref={cursorRef}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ display: 'inline-block',
|
|
|
+ left: `${zoomTimelinePixelsPerFrame * Math.trunc(currentFrame)}px`,
|
|
|
+ width: `${Math.max(zoomTimelinePixelsPerFrame, 1)}px`,
|
|
|
+ height: '100%',
|
|
|
+ backgroundColor: 'rgba(255, 0, 0, 0.4)',
|
|
|
+ borderLeft: '1px solid red',
|
|
|
+ }}
|
|
|
+ ></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|