Spaces:
Paused
Paused
| /** | |
| * Copyright (c) Meta Platforms, Inc. and affiliates. | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| import SelectedFrameHelper from '@/common/components/video/filmstrip/SelectedFrameHelper'; | |
| import {isPlayingAtom} from '@/demo/atoms'; | |
| import stylex from '@stylexjs/stylex'; | |
| import {useAtomValue, useSetAtom} from 'jotai'; | |
| import {CanvasSpace, Pt} from 'pts'; | |
| import {useCallback, useEffect, useMemo, useRef} from 'react'; | |
| import {PtsCanvas, PtsCanvasImperative} from 'react-pts-canvas'; | |
| import {VideoRef} from '../Video'; | |
| import {DecodeEvent, FrameUpdateEvent} from '../VideoWorkerBridge'; | |
| import useVideo from '../editor/useVideo'; | |
| import { | |
| drawFilmstrip, | |
| drawMarker, | |
| getPointerPosition, | |
| getTimeFromFrame, | |
| } from './FilmstripUtil'; | |
| import {selectedFrameHelperAtom} from './atoms'; | |
| import useDisableScrolling from './useDisableScrolling'; | |
| const styles = stylex.create({ | |
| container: { | |
| display: 'flex', | |
| flexDirection: 'column', | |
| }, | |
| filmstripWrapper: { | |
| position: 'relative', | |
| width: '100%', | |
| height: '5rem' /* 80px */, | |
| }, | |
| filmstrip: { | |
| position: 'absolute', | |
| top: 0, | |
| left: 0, | |
| bottom: 0, | |
| right: 0, | |
| cursor: 'col-resize', | |
| overflow: 'hidden', | |
| }, | |
| canvas: { | |
| width: '100%', | |
| height: '100%', | |
| }, | |
| }); | |
| export const PADDING_TOP = 30; | |
| export const PADDING_BOTTOM = 0; | |
| export default function VideoFilmstrip() { | |
| const video = useVideo(); | |
| const ptsCanvasRef = useRef<PtsCanvasImperative | null>(null); | |
| const filmstripRef = useRef<ImageBitmap | null>(null); | |
| const isPlayingOnPointerDownRef = useRef<boolean>(false); | |
| const isPlaying = useAtomValue(isPlayingAtom); | |
| const {enable: enableScrolling, disable: disableScrolling} = | |
| useDisableScrolling(); | |
| const pointerPositionRef = useRef<Pt | null>(null); | |
| const animateRAFHandle = useRef<number | null>(null); | |
| const selectedFrameHelper = useMemo(() => new SelectedFrameHelper(1, 1), []); | |
| const setSelectedFrameHelper = useSetAtom(selectedFrameHelperAtom); | |
| const fpsRef = useRef<number>(30); | |
| useEffect(() => { | |
| function onDecode(event: DecodeEvent) { | |
| video?.removeEventListener('decode', onDecode); | |
| fpsRef.current = event.fps; | |
| } | |
| video?.addEventListener('decode', onDecode); | |
| return () => { | |
| video?.removeEventListener('decode', onDecode); | |
| }; | |
| }, [video]); | |
| useEffect(() => { | |
| setSelectedFrameHelper(selectedFrameHelper); | |
| }, [setSelectedFrameHelper, selectedFrameHelper]); | |
| const computeFrame = useCallback( | |
| (normalizedPosition: number): {index: number} | null => { | |
| if (video == null) { | |
| return null; | |
| } | |
| const numFrames = video.numberOfFrames; | |
| const index = Math.min( | |
| Math.max(0, Math.floor(normalizedPosition * numFrames)), | |
| numFrames - 1, | |
| ); | |
| // The frame is needed for the CAE model. Do we still want to support it? | |
| // return {image: decodedVideo.frames[index], index: index}; | |
| return {index}; | |
| }, | |
| [video], | |
| ); | |
| const createFilmstrip = useCallback( | |
| async ( | |
| video: VideoRef | null, | |
| space: CanvasSpace | undefined, | |
| frameIndex?: number, | |
| ) => { | |
| if (video === null || space == undefined) { | |
| return; | |
| } | |
| const bitmap: ImageBitmap = await video?.createFilmstrip( | |
| space.width, | |
| space.height - (PADDING_TOP - PADDING_BOTTOM), | |
| ); | |
| filmstripRef.current = bitmap; | |
| selectedFrameHelper.reset(video.numberOfFrames, space.width, frameIndex); // also reset index to first frame | |
| return bitmap; | |
| }, | |
| [selectedFrameHelper], | |
| ); | |
| // Custom animation handler | |
| const handleRAF = useCallback(() => { | |
| animateRAFHandle.current = null; | |
| const space = ptsCanvasRef.current?.getSpace(); | |
| const form = ptsCanvasRef.current?.getForm(); | |
| if (space == undefined || form == undefined) { | |
| return; | |
| } | |
| // Clear space, in particular clearing the frame index number of | |
| // previous renders. | |
| space.clear(); | |
| drawFilmstrip(filmstripRef.current, space, form); | |
| const scanLabel = | |
| selectedFrameHelper.isScanning && | |
| pointerPositionRef.current !== null && | |
| fpsRef.current !== null && | |
| getTimeFromFrame( | |
| computeFrame(pointerPositionRef.current.x / space.width)?.index ?? 0, | |
| fpsRef.current, | |
| ); | |
| drawMarker( | |
| space, | |
| form, | |
| selectedFrameHelper, | |
| pointerPositionRef.current, | |
| scanLabel, | |
| fpsRef.current, | |
| ); | |
| }, [computeFrame, selectedFrameHelper]); | |
| const handleAnimate = useCallback(() => { | |
| if (animateRAFHandle.current === null) { | |
| animateRAFHandle.current = requestAnimationFrame(handleRAF); | |
| } | |
| }, [handleRAF]); | |
| const handleFrameUpdate = useCallback( | |
| (event: FrameUpdateEvent) => { | |
| if (!selectedFrameHelper.isScanning) { | |
| selectedFrameHelper.select(event.index); | |
| } | |
| handleAnimate(); | |
| }, | |
| [handleAnimate, selectedFrameHelper], | |
| ); | |
| // Register a frame update listener on the video to update the filmstrip | |
| // indicator when the video changes frames. | |
| useEffect(() => { | |
| video?.addEventListener('frameUpdate', handleFrameUpdate); | |
| return () => { | |
| video?.removeEventListener('frameUpdate', handleFrameUpdate); | |
| }; | |
| }, [video, handleFrameUpdate]); | |
| // Initiate filmstrip image | |
| useEffect(() => { | |
| const space = ptsCanvasRef.current?.getSpace(); | |
| async function onLoadStart() { | |
| await createFilmstrip(video, space, 0); | |
| handleAnimate(); | |
| } | |
| async function progress() { | |
| await createFilmstrip(video, space, 0); | |
| handleAnimate(); | |
| } | |
| void progress(); | |
| video?.addEventListener('loadstart', onLoadStart); | |
| video?.addEventListener('decode', progress); | |
| return () => { | |
| video?.removeEventListener('loadstart', onLoadStart); | |
| video?.removeEventListener('decode', progress); | |
| }; | |
| }, [createFilmstrip, selectedFrameHelper, handleAnimate, video]); | |
| return ( | |
| <div {...stylex.props(styles.container)}> | |
| <div {...stylex.props(styles.filmstripWrapper)}> | |
| <div {...stylex.props(styles.filmstrip)}> | |
| <PtsCanvas | |
| {...stylex.props(styles.canvas)} | |
| ref={ptsCanvasRef} | |
| background="transparent" | |
| resize={true} | |
| refresh={false} | |
| play={false} | |
| onPtsResize={async space => { | |
| if (video != null && space != undefined) { | |
| selectedFrameHelper.reset(video.numberOfFrames, space.width); | |
| } | |
| if (video !== null) { | |
| await createFilmstrip(video, space); | |
| } | |
| handleAnimate(); | |
| }} | |
| onPointerDown={event => { | |
| const canvas = ptsCanvasRef.current?.getCanvas(); | |
| canvas?.setPointerCapture(event.pointerId); | |
| // Disable page scrolling while interacting with the filmstrip | |
| disableScrolling(); | |
| pointerPositionRef.current = getPointerPosition(event); | |
| selectedFrameHelper.scan(true); | |
| // Pause the video when a user initially has their pointer down. | |
| // Playback will resume once the onPointerUp event is triggered. | |
| isPlayingOnPointerDownRef.current = isPlaying; | |
| if (isPlaying) { | |
| video?.pause(); | |
| } | |
| }} | |
| onPointerUp={event => { | |
| // Enable page scrolling after interaction with filmstrip is done | |
| enableScrolling(); | |
| const space = ptsCanvasRef.current?.getSpace(); | |
| if (space != undefined) { | |
| pointerPositionRef.current = getPointerPosition(event); | |
| selectedFrameHelper.scan(false); | |
| const frame = computeFrame( | |
| pointerPositionRef.current.x / space.size.x, | |
| ); | |
| if ( | |
| frame != null && | |
| selectedFrameHelper.index !== frame.index | |
| ) { | |
| selectedFrameHelper.select(frame.index); | |
| if (video !== null) { | |
| video.frame = frame.index; | |
| if (isPlayingOnPointerDownRef.current) { | |
| video.play(); | |
| } | |
| } | |
| } | |
| handleAnimate(); | |
| } | |
| pointerPositionRef.current = null; | |
| }} | |
| onPointerMove={event => { | |
| if ( | |
| !selectedFrameHelper.isScanning || | |
| pointerPositionRef.current === null | |
| ) { | |
| return; | |
| } | |
| const space = ptsCanvasRef.current?.getSpace(); | |
| const form = ptsCanvasRef.current?.getForm(); | |
| if ( | |
| selectedFrameHelper.isScanning && | |
| space != null && | |
| form != null | |
| ) { | |
| pointerPositionRef.current = getPointerPosition(event); | |
| const frame = computeFrame( | |
| pointerPositionRef.current.x / space.size.x, | |
| ); | |
| if (frame != null) { | |
| handleAnimate(); | |
| if (video !== null) { | |
| video.frame = frame.index; | |
| } | |
| } | |
| } | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |