/** * 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(null); const filmstripRef = useRef(null); const isPlayingOnPointerDownRef = useRef(false); const isPlaying = useAtomValue(isPlayingAtom); const {enable: enableScrolling, disable: disableScrolling} = useDisableScrolling(); const pointerPositionRef = useRef(null); const animateRAFHandle = useRef(null); const selectedFrameHelper = useMemo(() => new SelectedFrameHelper(1, 1), []); const setSelectedFrameHelper = useSetAtom(selectedFrameHelperAtom); const fpsRef = useRef(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 (
{ 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; } } } }} />
); }