App.tsx 16 KB

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