App.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. import React, {useEffect, useMemo, useRef, useState} from 'react'
  2. import './App.css'
  3. import io from 'socket.io-client'
  4. import Typography from '@mui/material/Typography'
  5. import Container from '@mui/material/Container'
  6. import ToggleButton from '@mui/material/ToggleButton'
  7. import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
  8. import {PB} from 'smartknobjs-proto'
  9. import {VideoInfo} from './types'
  10. import {Card, CardContent} from '@mui/material'
  11. import {exhaustiveCheck, findNClosest, INT32_MIN, lerp, NoUndefinedField} from './util'
  12. import {groupBy, parseInt} from 'lodash'
  13. const socket = io()
  14. const MIN_ZOOM = 0.01
  15. const MAX_ZOOM = 60
  16. const PIXELS_PER_POSITION = 10
  17. enum Mode {
  18. Scroll = 'Scroll',
  19. Frames = 'Frames',
  20. Speed = 'Speed',
  21. }
  22. type State = {
  23. mode: Mode
  24. playbackSpeed: number
  25. currentFrame: number
  26. zoomTimelinePixelsPerFrame: number
  27. }
  28. export type AppProps = {
  29. info: VideoInfo
  30. }
  31. export const App: React.FC<AppProps> = ({info}) => {
  32. const [isConnected, setIsConnected] = useState(socket.connected)
  33. const [state, setState] = useState<NoUndefinedField<PB.ISmartKnobState>>(
  34. PB.SmartKnobState.toObject(PB.SmartKnobState.create({config: PB.SmartKnobConfig.create()}), {
  35. defaults: true,
  36. }) as NoUndefinedField<PB.ISmartKnobState>,
  37. )
  38. const [derivedState, setDerivedState] = useState<State>({
  39. mode: Mode.Scroll,
  40. playbackSpeed: 0,
  41. currentFrame: 0,
  42. zoomTimelinePixelsPerFrame: 0.1,
  43. })
  44. useMemo(() => {
  45. setDerivedState((cur) => {
  46. const modeText = state.config.text
  47. if (modeText === Mode.Scroll) {
  48. const rawFrame = Math.trunc(
  49. ((state.currentPosition + state.subPositionUnit) * PIXELS_PER_POSITION) /
  50. cur.zoomTimelinePixelsPerFrame,
  51. )
  52. return {
  53. mode: Mode.Scroll,
  54. playbackSpeed: 0,
  55. currentFrame: Math.min(Math.max(rawFrame, 0), info.totalFrames - 1),
  56. zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
  57. }
  58. } else if (modeText === Mode.Frames) {
  59. return {
  60. mode: Mode.Frames,
  61. playbackSpeed: 0,
  62. currentFrame: state.currentPosition ?? 0,
  63. zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
  64. }
  65. } else if (modeText === Mode.Speed) {
  66. const normalizedWholeValue = state.currentPosition
  67. const normalizedFractional =
  68. Math.sign(state.subPositionUnit) *
  69. lerp(state.subPositionUnit * Math.sign(state.subPositionUnit), 0.1, 0.9, 0, 1)
  70. const normalized = normalizedWholeValue + normalizedFractional
  71. const speed = Math.sign(normalized) * Math.pow(2, Math.abs(normalized) - 1)
  72. return {
  73. mode: Mode.Speed,
  74. playbackSpeed: speed,
  75. currentFrame: cur.currentFrame,
  76. zoomTimelinePixelsPerFrame: cur.zoomTimelinePixelsPerFrame,
  77. }
  78. }
  79. return cur
  80. })
  81. }, [state.config.text, state.currentPosition, state.subPositionUnit])
  82. const totalPositions = Math.ceil((info.totalFrames * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
  83. const detentPositions = useMemo(() => {
  84. // Always include the first and last positions at detents
  85. const positionsToFrames = groupBy([0, ...info.boundaryFrames, info.totalFrames - 1], (frame) =>
  86. Math.round((frame * derivedState.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION),
  87. )
  88. console.log(JSON.stringify(positionsToFrames))
  89. return positionsToFrames
  90. }, [info.boundaryFrames, totalPositions, derivedState.zoomTimelinePixelsPerFrame])
  91. // Continuous config updates for scrolling, to update detent positions
  92. useMemo(() => {
  93. if (derivedState.mode === Mode.Scroll) {
  94. const config = PB.SmartKnobConfig.create({
  95. position: INT32_MIN,
  96. minPosition: 0,
  97. maxPosition: totalPositions - 1,
  98. positionWidthRadians: (8 * Math.PI) / 180,
  99. detentStrengthUnit: 2.5,
  100. endstopStrengthUnit: 1,
  101. snapPoint: 0.7,
  102. text: Mode.Scroll,
  103. detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), state.currentPosition, 5),
  104. snapPointBias: 0,
  105. })
  106. socket.emit('set_config', config)
  107. }
  108. }, [derivedState.mode, derivedState.zoomTimelinePixelsPerFrame, detentPositions, state.currentPosition])
  109. // For one-off config pushes, e.g. mode changes
  110. const pushConfig = (state: State) => {
  111. let config: PB.SmartKnobConfig
  112. if (state.mode === Mode.Scroll) {
  113. const position = Math.trunc((state.currentFrame * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION)
  114. config = PB.SmartKnobConfig.create({
  115. position,
  116. minPosition: 0,
  117. maxPosition: Math.trunc(
  118. ((info.totalFrames - 1) * state.zoomTimelinePixelsPerFrame) / PIXELS_PER_POSITION,
  119. ),
  120. positionWidthRadians: (8 * Math.PI) / 180,
  121. detentStrengthUnit: 2.5,
  122. endstopStrengthUnit: 1,
  123. snapPoint: 0.7,
  124. text: Mode.Scroll,
  125. detentPositions: findNClosest(Object.keys(detentPositions).map(parseInt), position, 5),
  126. snapPointBias: 0,
  127. })
  128. } else if (state.mode === Mode.Frames) {
  129. config = PB.SmartKnobConfig.create({
  130. position: state.currentFrame,
  131. minPosition: 0,
  132. maxPosition: info.totalFrames - 1,
  133. positionWidthRadians: (1.5 * Math.PI) / 180,
  134. detentStrengthUnit: 1,
  135. endstopStrengthUnit: 1,
  136. snapPoint: 1.1,
  137. text: Mode.Frames,
  138. detentPositions: [],
  139. snapPointBias: 0,
  140. })
  141. } else if (state.mode === Mode.Speed) {
  142. config = PB.SmartKnobConfig.create({
  143. position: state.playbackSpeed === 0 ? 0 : INT32_MIN,
  144. minPosition: state.currentFrame === 0 ? 0 : -6,
  145. maxPosition: state.currentFrame === info.totalFrames - 1 ? 0 : 6,
  146. positionWidthRadians: (60 * Math.PI) / 180,
  147. detentStrengthUnit: 1,
  148. endstopStrengthUnit: 1,
  149. snapPoint: 0.55,
  150. text: Mode.Speed,
  151. detentPositions: [],
  152. snapPointBias: 0.4,
  153. })
  154. } else {
  155. throw exhaustiveCheck(state.mode)
  156. }
  157. socket.emit('set_config', config)
  158. }
  159. const setCurrentFrame = (fn: (oldFrame: number) => number) => {
  160. setDerivedState((cur) => {
  161. const newState = {...cur}
  162. if (cur.mode === Mode.Speed) {
  163. newState.currentFrame = fn(cur.currentFrame)
  164. }
  165. return newState
  166. })
  167. }
  168. // Timer for speed-based playback
  169. useEffect(() => {
  170. const refreshInterval = 20
  171. const fps = info.frameRate * derivedState.playbackSpeed
  172. if (derivedState.mode === Mode.Speed && fps !== 0) {
  173. const timer = setInterval(() => {
  174. setCurrentFrame((oldFrame) => {
  175. const newFrame = oldFrame + (fps * refreshInterval) / 1000
  176. const oldFrameTrunc = Math.trunc(oldFrame)
  177. const newFrameTrunc = Math.trunc(newFrame)
  178. if (newFrame < 0 || newFrame >= info.totalFrames) {
  179. const clampedNewFrame = Math.min(Math.max(newFrame, 0), info.totalFrames - 1)
  180. if (oldFrame !== clampedNewFrame) {
  181. // If we've hit a boundary, push a config to set the bounds
  182. pushConfig({
  183. mode: Mode.Speed,
  184. playbackSpeed: 0,
  185. currentFrame: Math.trunc(clampedNewFrame),
  186. zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
  187. })
  188. }
  189. return clampedNewFrame
  190. } else {
  191. if (
  192. (oldFrameTrunc === 0 && newFrameTrunc > 0) ||
  193. (oldFrameTrunc === info.totalFrames - 1 && newFrameTrunc < info.totalFrames - 1)
  194. ) {
  195. // If we've left a boundary condition, push a config to reset the bounds
  196. pushConfig({
  197. mode: derivedState.mode,
  198. playbackSpeed: 0,
  199. currentFrame: newFrameTrunc,
  200. zoomTimelinePixelsPerFrame: derivedState.zoomTimelinePixelsPerFrame,
  201. })
  202. }
  203. return newFrame
  204. }
  205. })
  206. }, refreshInterval)
  207. return () => clearInterval(timer)
  208. }
  209. }, [derivedState.mode, derivedState.playbackSpeed, info.totalFrames, info.frameRate])
  210. // Socket.io subscription
  211. useEffect(() => {
  212. socket.on('connect', () => {
  213. setIsConnected(true)
  214. pushConfig(derivedState)
  215. })
  216. socket.on('disconnect', () => {
  217. setIsConnected(false)
  218. })
  219. socket.on('state', (input: {pb: PB.SmartKnobState}) => {
  220. const {pb: state} = input
  221. const stateObj = PB.SmartKnobState.toObject(state, {
  222. defaults: true,
  223. }) as NoUndefinedField<PB.ISmartKnobState>
  224. setState(stateObj)
  225. })
  226. return () => {
  227. socket.off('connect')
  228. socket.off('disconnect')
  229. socket.off('state')
  230. }
  231. }, [])
  232. return (
  233. <>
  234. <Container component="main" maxWidth="md">
  235. <Card>
  236. <CardContent>
  237. <Typography component="h1" variant="h5">
  238. Video Playback Control Demo
  239. </Typography>
  240. {isConnected || (
  241. <Typography component="h6" variant="h6">
  242. [Not connected]
  243. </Typography>
  244. )}
  245. <ToggleButtonGroup
  246. color="primary"
  247. value={derivedState.mode}
  248. exclusive
  249. onChange={(e, value: Mode | null) => {
  250. if (value === null) {
  251. return
  252. }
  253. pushConfig({
  254. ...derivedState,
  255. mode: value,
  256. })
  257. }}
  258. aria-label="Mode"
  259. >
  260. {Object.keys(Mode).map((mode) => (
  261. <ToggleButton value={mode} key={mode}>
  262. {mode}
  263. </ToggleButton>
  264. ))}
  265. </ToggleButtonGroup>
  266. <Typography>
  267. Frame {Math.trunc(derivedState.currentFrame)} / {info.totalFrames - 1}
  268. <br />
  269. Speed {Math.trunc(derivedState.playbackSpeed * 10) / 10}
  270. </Typography>
  271. </CardContent>
  272. </Card>
  273. <Timeline
  274. info={info}
  275. currentFrame={derivedState.currentFrame}
  276. zoomTimelinePixelsPerFrame={derivedState.zoomTimelinePixelsPerFrame}
  277. adjustZoom={(factor) => {
  278. setDerivedState((cur) => {
  279. const newZoom = Math.min(
  280. Math.max(cur.zoomTimelinePixelsPerFrame * factor, MIN_ZOOM),
  281. MAX_ZOOM,
  282. )
  283. console.log(factor, newZoom)
  284. return {
  285. ...cur,
  286. zoomTimelinePixelsPerFrame: newZoom,
  287. }
  288. })
  289. }}
  290. />
  291. <Card>
  292. <CardContent>
  293. <div>{JSON.stringify(detentPositions)}</div>
  294. </CardContent>
  295. </Card>
  296. </Container>
  297. </>
  298. )
  299. }
  300. export type TimelineProps = {
  301. info: VideoInfo
  302. currentFrame: number
  303. zoomTimelinePixelsPerFrame: number
  304. adjustZoom: (factor: number) => void
  305. }
  306. export const Timeline: React.FC<TimelineProps> = ({info, currentFrame, zoomTimelinePixelsPerFrame, adjustZoom}) => {
  307. const gradients = [
  308. 'linear-gradient( 135deg, #FDEB71 10%, #F8D800 100%)',
  309. 'linear-gradient( 135deg, #ABDCFF 10%, #0396FF 100%)',
  310. 'linear-gradient( 135deg, #FEB692 10%, #EA5455 100%)',
  311. 'linear-gradient( 135deg, #CE9FFC 10%, #7367F0 100%)',
  312. 'linear-gradient( 135deg, #90F7EC 10%, #32CCBC 100%)',
  313. ]
  314. const timelineRef = useRef<HTMLDivElement>(null)
  315. const cursorRef = useRef<HTMLDivElement>(null)
  316. useEffect(() => {
  317. const handleWheel = (event: HTMLElementEventMap['wheel']) => {
  318. const delta = event.deltaY
  319. if (delta) {
  320. event.preventDefault()
  321. adjustZoom(1 - delta / 500)
  322. }
  323. }
  324. timelineRef.current?.addEventListener('wheel', handleWheel)
  325. return () => {
  326. timelineRef.current?.removeEventListener('wheel', handleWheel)
  327. }
  328. }, [])
  329. useEffect(() => {
  330. cursorRef.current?.scrollIntoView()
  331. }, [currentFrame, zoomTimelinePixelsPerFrame])
  332. return (
  333. <div
  334. className="timeline-container"
  335. ref={timelineRef}
  336. style={{
  337. width: '100%',
  338. margin: '10px auto',
  339. overflowX: 'scroll',
  340. }}
  341. >
  342. <div
  343. className="timeline"
  344. style={{
  345. position: 'relative',
  346. display: 'inline-block',
  347. height: '80px',
  348. width: `${zoomTimelinePixelsPerFrame * info.totalFrames}px`,
  349. backgroundColor: '#dde',
  350. }}
  351. >
  352. {[...info.boundaryFrames, info.totalFrames].map((f, i, a) => {
  353. const lengthFrames = f - (a[i - 1] ?? 0)
  354. const widthPixels = zoomTimelinePixelsPerFrame * lengthFrames
  355. return (
  356. <div
  357. key={`clip-${f}`}
  358. className="video-clip"
  359. style={{
  360. position: 'relative',
  361. display: 'inline-block',
  362. top: '10px',
  363. height: '60px',
  364. width: `${widthPixels}px`,
  365. backgroundImage: gradients[i % gradients.length],
  366. }}
  367. ></div>
  368. )
  369. })}
  370. <div
  371. className="playback-cursor"
  372. ref={cursorRef}
  373. style={{
  374. position: 'absolute',
  375. display: 'inline-block',
  376. left: `${zoomTimelinePixelsPerFrame * Math.trunc(currentFrame)}px`,
  377. width: `${Math.max(zoomTimelinePixelsPerFrame, 1)}px`,
  378. height: '100%',
  379. backgroundColor: 'rgba(255, 0, 0, 0.4)',
  380. borderLeft: '1px solid red',
  381. }}
  382. ></div>
  383. </div>
  384. </div>
  385. )
  386. }