| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- import React, {useEffect, useMemo, useRef, useState} from 'react'
- 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 {VideoInfo} from './types'
- 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 = 60
- const PIXELS_PER_POSITION = 10
- enum Mode {
- Scroll = 'Scroll',
- Frames = 'Frames',
- Speed = 'Speed',
- }
- type State = {
- mode: Mode
- playbackSpeed: number
- currentFrame: number
- zoomTimelinePixelsPerFrame: number
- }
- export type AppProps = {
- info: VideoInfo
- }
- export const App: React.FC<AppProps> = ({info}) => {
- const [isConnected, setIsConnected] = useState(socket.connected)
- 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,
- currentFrame: 0,
- 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) {
- const position = Math.trunc((state.currentFrame * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
- config = PB.SmartKnobConfig.create({
- position,
- minPosition: 0,
- 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: findNClosest(Object.keys(detentPositions).map(parseInt), position, 5),
- snapPointBias: 0,
- })
- } else if (state.mode === Mode.Frames) {
- config = PB.SmartKnobConfig.create({
- position: state.currentFrame,
- minPosition: 0,
- maxPosition: info.totalFrames - 1,
- positionWidthRadians: (1.5 * Math.PI) / 180,
- detentStrengthUnit: 1,
- endstopStrengthUnit: 1,
- snapPoint: 1.1,
- text: Mode.Frames,
- detentPositions: [],
- snapPointBias: 0,
- })
- } else if (state.mode === Mode.Speed) {
- config = PB.SmartKnobConfig.create({
- position: state.playbackSpeed === 0 ? 0 : INT32_MIN,
- minPosition: state.currentFrame === 0 ? 0 : -6,
- maxPosition: state.currentFrame === info.totalFrames - 1 ? 0 : 6,
- positionWidthRadians: (60 * Math.PI) / 180,
- detentStrengthUnit: 1,
- endstopStrengthUnit: 1,
- snapPoint: 0.55,
- text: Mode.Speed,
- detentPositions: [],
- snapPointBias: 0.4,
- })
- } else {
- throw exhaustiveCheck(state.mode)
- }
- socket.emit('set_config', config)
- }
- const setCurrentFrame = (fn: (oldFrame: number) => number) => {
- setDerivedState((cur) => {
- const newState = {...cur}
- if (cur.mode === Mode.Speed) {
- newState.currentFrame = fn(cur.currentFrame)
- }
- return newState
- })
- }
- // Timer for speed-based playback
- useEffect(() => {
- const refreshInterval = 20
- const fps = info.frameRate * derivedState.playbackSpeed
- if (derivedState.mode === Mode.Speed && fps !== 0) {
- const timer = setInterval(() => {
- setCurrentFrame((oldFrame) => {
- const newFrame = oldFrame + (fps * refreshInterval) / 1000
- const oldFrameTrunc = Math.trunc(oldFrame)
- const newFrameTrunc = Math.trunc(newFrame)
- if (newFrame < 0 || newFrame >= info.totalFrames) {
- const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
- 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 (
- (oldFrameTrunc === 0 && newFrameTrunc > 0) ||
- (oldFrameTrunc === info.totalFrames - 1 && newFrameTrunc < info.totalFrames - 1)
- ) {
- // If we've left a boundary condition, push a config to reset the bounds
- pushConfig({
- mode: derivedState.mode,
- playbackSpeed: 0,
- currentFrame: newFrameTrunc,
- zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
- })
- }
- return newFrame
- }
- })
- }, refreshInterval)
- return () => clearInterval(timer)
- }
- }, [derivedState.mode, derivedState.playbackSpeed, info.totalFrames, info.frameRate])
- // Socket.io subscription
- useEffect(() => {
- socket.on('connect', () => {
- setIsConnected(true)
- pushConfig(derivedState)
- })
- socket.on('disconnect', () => {
- setIsConnected(false)
- })
- socket.on('state', (input: {pb: PB.SmartKnobState}) => {
- const {pb: state} = input
- const stateObj = PB.SmartKnobState.toObject(state, {
- defaults: true,
- }) as NoUndefinedField<PB.ISmartKnobState>
- setState(stateObj)
- })
- return () => {
- socket.off('connect')
- socket.off('disconnect')
- socket.off('state')
- }
- }, [])
- return (
- <>
- <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
- }
- pushConfig({
- ...derivedState,
- mode: value,
- })
- }}
- aria-label="Mode"
- >
- {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 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>
- )
- }
|