Commit 17d316f3 authored by suily's avatar suily
Browse files

Initial commit

parents
Pipeline #3368 failed with stages
in 0 seconds
/**
* 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 {MessagesEventMap} from '@/common/components/snackbar/DemoMessagesSnackbarUtils';
import useMessagesSnackbar from '@/common/components/snackbar/useMessagesSnackbar';
import {messageMapAtom} from '@/demo/atoms';
import {useAtom} from 'jotai';
import {useCallback} from 'react';
type State = {
enqueueMessage: (messageType: keyof MessagesEventMap) => void;
clearMessage: () => void;
};
export default function useDemoMessagesSnackbar(): State {
const [messageMap, setMessageMap] = useAtom(messageMapAtom);
const {enqueueMessage: enqueue, clearMessage} = useMessagesSnackbar();
const enqueueMessage = useCallback(
(messageType: keyof MessagesEventMap) => {
const {text, shown, options} = messageMap[messageType];
if (!options?.repeat && shown === true) {
return;
}
enqueue(text, options);
const newState = {...messageMap};
newState[messageType].shown = true;
setMessageMap(newState);
},
[enqueue, messageMap, setMessageMap],
);
return {enqueueMessage, clearMessage};
}
/**
* 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 {useAtom} from 'jotai';
import {useEffect, useRef} from 'react';
import {Message, messageAtom} from '@/common/components/snackbar/snackbarAtoms';
export default function useExpireMessage() {
const [message, setMessage] = useAtom(messageAtom);
const messageRef = useRef<Message | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
messageRef.current = message;
}, [message]);
useEffect(() => {
function resetInterval() {
if (intervalRef.current != null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
if (intervalRef.current == null && message != null && message.expire) {
intervalRef.current = setInterval(() => {
const prevMessage = messageRef.current;
if (prevMessage == null) {
setMessage(null);
resetInterval();
return;
}
const messageDuration = Date.now() - prevMessage.startTime;
if (messageDuration > prevMessage.duration) {
setMessage(null);
resetInterval();
return;
}
setMessage({
...prevMessage,
progress: messageDuration / prevMessage.duration,
});
}, 20);
}
}, [message, setMessage]);
useEffect(() => {
return () => {
if (intervalRef.current != null) {
clearInterval(intervalRef.current);
}
};
}, []);
}
/**
* 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 {useSetAtom} from 'jotai';
import {useCallback} from 'react';
import {
MessageType,
messageAtom,
} from '@/common/components/snackbar/snackbarAtoms';
export type EnqueueOption = {
duration?: number;
type?: MessageType;
expire?: boolean;
showClose?: boolean;
showReset?: boolean;
};
type State = {
clearMessage: () => void;
enqueueMessage: (message: string, options?: EnqueueOption) => void;
};
export default function useMessagesSnackbar(): State {
const setMessage = useSetAtom(messageAtom);
const enqueueMessage = useCallback(
(message: string, options?: EnqueueOption) => {
setMessage({
text: message,
type: options?.type ?? 'info',
duration: options?.duration ?? 5000,
progress: 0,
startTime: Date.now(),
expire: options?.expire ?? true,
showClose: options?.showClose ?? true,
showReset: options?.showReset ?? false,
});
},
[setMessage],
);
function clearMessage() {
setMessage(null);
}
return {enqueueMessage, clearMessage};
}
/**
* 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 ObjectsToolbar from '@/common/components/annotations/ObjectsToolbar';
import EffectsToolbar from '@/common/components/effects/EffectsToolbar';
import MoreOptionsToolbar from '@/common/components/options/MoreOptionsToolbar';
import type {CSSProperties} from 'react';
type Props = {
tabIndex: number;
onTabChange: (newIndex: number) => void;
};
export default function DesktopToolbar({tabIndex, onTabChange}: Props) {
const toolbarShadow: CSSProperties = {
boxShadow: '0px 1px 3px 1px rgba(0,0,0,.25)',
transition: 'box-shadow 0.8s ease-out',
};
const tabs = [
<ObjectsToolbar key="objects" onTabChange={onTabChange} />,
<EffectsToolbar key="effects" onTabChange={onTabChange} />,
<MoreOptionsToolbar key="options" onTabChange={onTabChange} />,
];
return (
<div
style={toolbarShadow}
className="bg-graydark-800 text-white md:basis-[350px] lg:basis-[435px] shrink-0 rounded-xl">
{tabs[tabIndex]}
</div>
);
}
/**
* 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 MobileObjectsToolbar from '@/common/components/annotations/MobileObjectsToolbar';
import MobileEffectsToolbar from '@/common/components/effects/MobileEffectsToolbar';
import MoreOptionsToolbar from '@/common/components/options/MoreOptionsToolbar';
type Props = {
tabIndex: number;
onTabChange: (newIndex: number) => void;
};
export default function MobileToolbar({tabIndex, onTabChange}: Props) {
const tabs = [
<MobileObjectsToolbar key="objects" onTabChange={onTabChange} />,
<MobileEffectsToolbar key="effects" onTabChange={onTabChange} />,
<MoreOptionsToolbar key="more-options" onTabChange={onTabChange} />,
];
return (
<div className="relative flex flex-col bg-black">{tabs[tabIndex]}</div>
);
}
/**
* 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 useListenToStreamingState from '@/common/components/toolbar/useListenToStreamingState';
import useToolbarTabs from '@/common/components/toolbar/useToolbarTabs';
import useVideo from '@/common/components/video/editor/useVideo';
import useVideoEffect from '@/common/components/video/editor/useVideoEffect';
import {EffectIndex} from '@/common/components/video/effects/Effects';
import useScreenSize from '@/common/screen/useScreenSize';
import {
codeEditorOpenedAtom,
isPlayingAtom,
isStreamingAtom,
} from '@/demo/atoms';
import {useAtom, useAtomValue, useSetAtom} from 'jotai';
import {useCallback, useEffect} from 'react';
import DesktopToolbar from './DesktopToolbar';
import MobileToolbar from './MobileToolbar';
import {OBJECT_TOOLBAR_INDEX} from './ToolbarConfig';
export default function Toolbar() {
const [tabIndex, setTabIndex] = useToolbarTabs();
const video = useVideo();
const setIsPlaying = useSetAtom(isPlayingAtom);
const [isStreaming, setIsStreaming] = useAtom(isStreamingAtom);
const codeEditorOpened = useAtomValue(codeEditorOpenedAtom);
const {isMobile} = useScreenSize();
const setEffect = useVideoEffect();
const resetEffects = useCallback(() => {
setEffect('Original', EffectIndex.BACKGROUND, {variant: 0});
setEffect('Overlay', EffectIndex.HIGHLIGHT, {variant: 0});
}, [setEffect]);
const handleStopVideo = useCallback(() => {
if (isStreaming) {
video?.abortStreamMasks();
} else {
video?.pause();
}
}, [video, isStreaming]);
const handleTabChange = useCallback(
(newIndex: number) => {
if (newIndex === OBJECT_TOOLBAR_INDEX) {
handleStopVideo();
resetEffects();
}
setTabIndex(newIndex);
},
[handleStopVideo, resetEffects, setTabIndex],
);
useListenToStreamingState();
useEffect(() => {
function onPlay() {
setIsPlaying(true);
}
function onPause() {
setIsPlaying(false);
}
video?.addEventListener('play', onPlay);
video?.addEventListener('pause', onPause);
return () => {
video?.removeEventListener('play', onPlay);
video?.removeEventListener('pause', onPause);
};
}, [video, resetEffects, setIsStreaming, setIsPlaying]);
if (codeEditorOpened) {
return null;
}
return isMobile ? (
<MobileToolbar tabIndex={tabIndex} onTabChange={handleTabChange} />
) : (
<DesktopToolbar tabIndex={tabIndex} onTabChange={handleTabChange} />
);
}
/**
* 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 GradientBorder from '@/common/components/button/GradientBorder';
import useScreenSize from '@/common/screen/useScreenSize';
import {BLUE_PINK_FILL_BR} from '@/theme/gradientStyle';
import type {CarbonIconType} from '@carbon/icons-react';
import {Loading} from 'react-daisyui';
type Props = {
isDisabled?: boolean;
isActive?: boolean;
icon: CarbonIconType;
title: string;
badge?: React.ReactNode;
variant: 'toggle' | 'button' | 'gradient' | 'flat';
span?: 1 | 2;
loadingProps?: {
loading: boolean;
label?: string;
};
onClick: () => void;
};
export default function ToolbarActionIcon({
variant,
isDisabled = false,
isActive = false,
title,
badge,
loadingProps,
icon: Icon,
span = 1,
onClick,
}: Props) {
const {isMobile} = useScreenSize();
const isLoading = loadingProps?.loading === true;
function handleClick() {
if (isDisabled) {
return;
}
onClick();
}
const ButtonBase = (
<div
onClick={handleClick}
className={`relative rounded-lg h-full flex items-center justify-center select-none
${!isDisabled && 'cursor-pointer hover:bg-black'}
${span === 1 && 'col-span-1'}
${span === 2 && 'col-span-2'}
${variant === 'button' && (isDisabled ? 'bg-graydark-500 text-gray-300' : 'bg-graydark-700 hover:bg-graydark-800 text-white')}
${variant === 'toggle' && (isActive ? BLUE_PINK_FILL_BR : 'bg-inherit')}
${variant === 'flat' && (isDisabled ? ' text-gray-600' : 'text-white')}
`}>
<div className="py-4 px-2">
<div className="flex items-center justify-center">
{isLoading ? (
<Loading size="md" className="mx-auto" />
) : (
<Icon
size={isMobile ? 24 : 28}
color={isActive ? 'white' : 'black'}
className={`mx-auto ${isDisabled ? 'text-gray-300' : 'text-white'}`}
/>
)}
</div>
<div
className={`mt-1 md:mt-2 text-center text-xs font-bold ${isActive && 'text-white'}`}>
{isLoading && loadingProps?.label != null
? loadingProps.label
: title}
</div>
{isActive && badge}
</div>
</div>
);
return variant == 'gradient' ? (
<GradientBorder rounded={false} className="rounded-lg h-full text-white">
{ButtonBase}
</GradientBorder>
) : (
ButtonBase
);
}
/**
* 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 {spacing} from '@/theme/tokens.stylex';
import stylex from '@stylexjs/stylex';
import {PropsWithChildren} from 'react';
const styles = stylex.create({
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: {
default: spacing[2],
'@media screen and (max-width: 768px)': spacing[4],
},
paddingBottom: spacing[6],
paddingHorizontal: spacing[6],
},
});
export default function ToolbarBottomActionsWrapper({
children,
}: PropsWithChildren) {
return <div {...stylex.props(styles.container)}>{children}</div>;
}
/**
* 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.
*/
export const OBJECT_TOOLBAR_INDEX = 0;
export const EFFECT_TOOLBAR_INDEX = 1;
export const MORE_OPTIONS_TOOLBAR_INDEX = 2;
/**
* 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 {ReactNode} from 'react';
import ToolbarProgressChip from './ToolbarProgressChip';
type Props = {
title: string;
description?: string;
bottomSection?: ReactNode;
showProgressChip?: boolean;
className?: string;
};
export default function ToolbarHeaderWrapper({
title,
description,
bottomSection,
showProgressChip = true,
className,
}: Props) {
return (
<div
className={`flex flex-col gap-2 p-8 border-b border-b-black ${className}`}>
<div className="flex items-center">
{showProgressChip && <ToolbarProgressChip />}
<h2 className="text-xl">{title}</h2>
</div>
{description != null && (
<div className="flex-1 text-gray-400">{description}</div>
)}
{bottomSection != null && bottomSection}
</div>
);
}
/**
* 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 {OBJECT_TOOLBAR_INDEX} from '@/common/components/toolbar/ToolbarConfig';
import useToolbarTabs from '@/common/components/toolbar/useToolbarTabs';
import {streamingStateAtom} from '@/demo/atoms';
import {useAtomValue} from 'jotai';
import {useMemo} from 'react';
import {Loading} from 'react-daisyui';
const TOTAL_DEMO_STEPS = 3;
export default function ToolbarProgressChip() {
const [toolbarIndex] = useToolbarTabs();
const streamingState = useAtomValue(streamingStateAtom);
const showLoader = useMemo(() => {
return streamingState === 'partial' || streamingState === 'requesting';
}, [streamingState]);
function getStepValue() {
if (toolbarIndex === OBJECT_TOOLBAR_INDEX) {
return streamingState !== 'full' ? 1 : 2;
}
return 3;
}
return (
<span className="inline-flex items-center justify-center rounded-full text-xs md:text-sm font-medium bg-white text-black w-10 md:w-12 h-5 md:h-6 mr-2 shrink-0 ">
{showLoader ? (
<Loading className="w-2 md:w-4" />
) : (
`${getStepValue()}/${TOTAL_DEMO_STEPS}`
)}
</span>
);
}
/**
* 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 {PropsWithChildren} from 'react';
type Props = PropsWithChildren<{
title: string;
borderBottom?: boolean;
}>;
export default function ToolbarSection({
children,
title,
borderBottom = false,
}: Props) {
return (
<div className={`p-6 ${borderBottom && 'border-b border-black'}`}>
<div className="font-bold ml-2">{title}</div>
<div className="grid grid-cols-4 gap-2 mt-2 md:mt-6">{children}</div>
</div>
);
}
/**
* 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 {StreamingStateUpdateEvent} from '@/common/components/video/VideoWorkerBridge';
import useVideo from '@/common/components/video/editor/useVideo';
import {StreamingState} from '@/common/tracker/Tracker';
import {isStreamingAtom, streamingStateAtom} from '@/demo/atoms';
import {useAtom} from 'jotai';
import {useEffect} from 'react';
export default function useListenToStreamingState(): {
isStreaming: boolean;
streamingState: StreamingState;
} {
const [streamingState, setStreamingState] = useAtom(streamingStateAtom);
const [isStreaming, setIsStreaming] = useAtom(isStreamingAtom);
const video = useVideo();
useEffect(() => {
function onStreamingStateUpdate(event: StreamingStateUpdateEvent) {
setStreamingState(event.state);
}
function onStreamingStarted() {
setIsStreaming(true);
}
function onStreamingCompleted() {
setIsStreaming(false);
}
video?.addEventListener('streamingStateUpdate', onStreamingStateUpdate);
video?.addEventListener('streamingStarted', onStreamingStarted);
video?.addEventListener('streamingCompleted', onStreamingCompleted);
return () => {
video?.removeEventListener(
'streamingStateUpdate',
onStreamingStateUpdate,
);
video?.removeEventListener('streamingStarted', onStreamingStarted);
video?.removeEventListener('streamingCompleted', onStreamingCompleted);
};
}, [video, setStreamingState, setIsStreaming]);
return {isStreaming, streamingState};
}
/**
* 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 {toolbarTabIndex} from '@/demo/atoms';
import {useAtom} from 'jotai';
type State = [tabIndex: number, setTabIndex: (tabIndex: number) => void];
export default function useToolbarTabs(): State {
return useAtom(toolbarTabIndex);
}
/**
* 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 {useCallback, useState} from 'react';
type ThrottleOptions = {
enableThrottling?: boolean;
};
type State = {
isThrottled: boolean;
maxThrottles: boolean;
throttle: (callback: () => void, options?: ThrottleOptions) => void;
};
export default function useFunctionThrottle(
initialDelay: number,
numThrottles: number,
): State {
const [isThrottled, setIsThrottled] = useState<boolean>(false);
const [lastClickTime, setLastClickTime] = useState<number | null>(null);
const [numTimesThrottled, setNumTimesThrottled] = useState<number>(1);
/**
* The following function's callback gets throttled when the time between two
* executions is less than a threshold.
*
* The threshold is calculated linearly by multiplying the initial delay
* and the number of times the button has been throttled. The button can be
* throttled up to numThrottles times.
*
* The function has an optional flag - enableThrottling - which allows a callsite
* to optionally disable throttling. This is useful in cases where throttling may
* not be necessary. (e.g. for the Track & Play button, we would only like to
* throttle after a stream is aborted.)
*/
const throttle = useCallback(
(
callback: () => void,
options: ThrottleOptions = {
enableThrottling: true,
},
) => {
if (isThrottled) {
return;
}
const currentTime = Date.now();
if (lastClickTime == null) {
callback();
setLastClickTime(currentTime);
return;
}
const timeBetweenClicks = currentTime - lastClickTime;
const delay = initialDelay * numTimesThrottled;
const shouldThrottle =
options.enableThrottling && delay > timeBetweenClicks;
if (shouldThrottle) {
setIsThrottled(true);
setTimeout(() => {
setIsThrottled(false);
}, delay);
setNumTimesThrottled(prev => {
return prev === numThrottles ? numThrottles : prev + 1;
});
}
callback();
setLastClickTime(currentTime);
},
[initialDelay, numThrottles, isThrottled, lastClickTime, numTimesThrottled],
);
return {
isThrottled,
maxThrottles: numTimesThrottled === numThrottles,
throttle,
};
}
/**
* 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 type {VideoGalleryTriggerProps} from '@/common/components/gallery/DemoVideoGalleryModal';
import DemoVideoGalleryModal from '@/common/components/gallery/DemoVideoGalleryModal';
import useVideo from '@/common/components/video/editor/useVideo';
import Logger from '@/common/logger/Logger';
import {isStreamingAtom, uploadingStateAtom, VideoData} from '@/demo/atoms';
import {useAtomValue, useSetAtom} from 'jotai';
import {ComponentType, useCallback} from 'react';
import {useNavigate} from 'react-router-dom';
type Props = {
videoGalleryModalTrigger?: ComponentType<VideoGalleryTriggerProps>;
showUploadInGallery?: boolean;
onChangeVideo?: () => void;
};
export default function ChangeVideoModal({
videoGalleryModalTrigger: VideoGalleryModalTriggerComponent,
showUploadInGallery = true,
onChangeVideo,
}: Props) {
const isStreaming = useAtomValue(isStreamingAtom);
const setUploadingState = useSetAtom(uploadingStateAtom);
const video = useVideo();
const navigate = useNavigate();
const handlePause = useCallback(() => {
video?.pause();
}, [video]);
function handlePauseOrAbortVideo() {
if (isStreaming) {
video?.abortStreamMasks();
} else {
handlePause();
}
}
function handleSwitchVideos(video: VideoData) {
// Retain any search parameter
navigate(
{
pathname: location.pathname,
search: location.search,
},
{
state: {
video,
},
},
);
onChangeVideo?.();
}
function handleUploadVideoError(error: Error) {
setUploadingState('error');
Logger.error(error);
}
return (
<DemoVideoGalleryModal
trigger={VideoGalleryModalTriggerComponent}
showUploadInGallery={showUploadInGallery}
onOpen={handlePauseOrAbortVideo}
onSelect={handleSwitchVideos}
onUploadVideoError={handleUploadVideoError}
/>
);
}
/**
* 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.
*/
type EventMap<WorkerEventMap> = {
type: keyof WorkerEventMap;
listener: (ev: WorkerEventMap[keyof WorkerEventMap]) => unknown;
};
export class EventEmitter<WorkerEventMap> {
listeners: EventMap<WorkerEventMap>[] = [];
trigger<K extends keyof WorkerEventMap>(type: K, ev: WorkerEventMap[K]) {
this.listeners
.filter(listener => type === listener.type)
.forEach(({listener}) => {
setTimeout(() => listener(ev), 0);
});
}
addEventListener<K extends keyof WorkerEventMap>(
type: K,
listener: (ev: WorkerEventMap[K]) => unknown,
): void {
// @ts-expect-error Incorrect typing. Not sure how to correctly type it
this.listeners.push({type, listener});
}
removeEventListener<K extends keyof WorkerEventMap>(
type: K,
listener: (ev: WorkerEventMap[K]) => unknown,
): void {
this.listeners = this.listeners.filter(
existingListener =>
!(
existingListener.type === type &&
existingListener.listener === listener
),
);
}
destroy() {
this.listeners.length = 0;
}
}
/**
* 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 {BaseTracklet, SegmentationPoint} from '@/common/tracker/Tracker';
import {TrackerOptions, Trackers} from '@/common/tracker/Trackers';
import {PauseFilled, PlayFilledAlt} from '@carbon/icons-react';
import stylex, {StyleXStyles} from '@stylexjs/stylex';
import {
CSSProperties,
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import {Button} from 'react-daisyui';
import {EffectIndex, Effects} from '@/common/components/video/effects/Effects';
import useReportError from '@/common/error/useReportError';
import Logger from '@/common/logger/Logger';
import {isPlayingAtom, isVideoLoadingAtom} from '@/demo/atoms';
import {color} from '@/theme/tokens.stylex';
import {useAtom} from 'jotai';
import useResizeObserver from 'use-resize-observer';
import VideoLoadingOverlay from './VideoLoadingOverlay';
import {
StreamingStateUpdateEvent,
VideoWorkerEventMap,
} from './VideoWorkerBridge';
import {EffectOptions} from './effects/Effect';
import useVideoWorker from './useVideoWorker';
const styles = stylex.create({
container: {
position: 'relative',
width: '100%',
height: '100%',
},
canvasContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: color['gray-800'],
width: '100%',
height: '100%',
},
controls: {
position: 'absolute',
bottom: 0,
left: 0,
width: '100%',
padding: 8,
background: 'linear-gradient(#00000000, #000000ff)',
},
controlButton: {
color: 'white',
},
});
type Props = {
src: string;
width: number;
height: number;
loading?: boolean;
containerStyle?: StyleXStyles<{
position: CSSProperties['position'];
}>;
canvasStyle?: StyleXStyles<{
width: CSSProperties['width'];
}>;
controls?: boolean;
createVideoWorker?: () => Worker;
};
export type VideoRef = {
getCanvas(): HTMLCanvasElement | null;
get width(): number;
get height(): number;
get frame(): number;
set frame(index: number);
get numberOfFrames(): number;
play(): void;
pause(): void;
stop(): void;
previousFrame(): void;
nextFrame(): void;
setEffect(
name: keyof Effects,
index: EffectIndex,
options?: EffectOptions,
): void;
encode(): void;
streamMasks(): void;
abortStreamMasks(): Promise<void>;
addEventListener<K extends keyof VideoWorkerEventMap>(
type: K,
listener: (ev: VideoWorkerEventMap[K]) => unknown,
): void;
removeEventListener<K extends keyof VideoWorkerEventMap>(
type: K,
listener: (ev: VideoWorkerEventMap[K]) => unknown,
): void;
createFilmstrip(width: number, height: number): Promise<ImageBitmap>;
// Tracker
initializeTracker(name: keyof Trackers, options?: TrackerOptions): void;
startSession(videoUrl: string): Promise<string | null>;
closeSession(): void;
logAnnotations(): void;
createTracklet(): Promise<BaseTracklet>;
deleteTracklet(trackletId: number): Promise<void>;
updatePoints(trackletId: number, points: SegmentationPoint[]): void;
clearPointsInVideo(): Promise<boolean>;
getWorker_ONLY_USE_WITH_CAUTION(): Worker;
};
export default forwardRef<VideoRef, Props>(function Video(
{
src,
width,
height,
containerStyle,
canvasStyle,
createVideoWorker,
controls = false,
loading = false,
},
ref,
) {
const reportError = useReportError();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isPlaying, setIsPlaying] = useAtom(isPlayingAtom);
const [isVideoLoading, setIsVideoLoading] = useAtom(isVideoLoadingAtom);
const bridge = useVideoWorker(src, canvasRef, {
createVideoWorker,
});
const {
ref: resizeObserverRef,
width: resizeWidth = 1,
height: resizeHeight = 1,
} = useResizeObserver<HTMLDivElement>();
const canvasHeight = useMemo(() => {
const resizeRatio = resizeWidth / width;
return Math.min(height * resizeRatio, resizeHeight);
}, [resizeWidth, height, width, resizeHeight]);
useImperativeHandle(
ref,
() => ({
getCanvas() {
return canvasRef.current;
},
get width() {
return bridge.width;
},
get height() {
return bridge.width;
},
get frame() {
return bridge.frame;
},
set frame(index: number) {
bridge.frame = index;
},
get numberOfFrames() {
return bridge.numberOfFrames;
},
play(): void {
bridge.play();
},
pause(): void {
bridge.pause();
},
stop(): void {
bridge.stop();
},
previousFrame(): void {
bridge.previousFrame();
},
nextFrame(): void {
bridge.nextFrame();
},
setEffect(
name: keyof Effects,
index: number,
options?: EffectOptions,
): void {
bridge.setEffect(name, index, options);
},
encode(): void {
bridge.encode();
},
streamMasks(): void {
bridge.streamMasks();
},
abortStreamMasks(): Promise<void> {
return bridge.abortStreamMasks();
},
addEventListener<K extends keyof VideoWorkerEventMap>(
type: K,
listener: (ev: VideoWorkerEventMap[K]) => unknown,
): void {
bridge.addEventListener(type, listener);
},
removeEventListener<K extends keyof VideoWorkerEventMap>(
type: K,
listener: (ev: VideoWorkerEventMap[K]) => unknown,
): void {
bridge.removeEventListener(type, listener);
},
createFilmstrip(width: number, height: number): Promise<ImageBitmap> {
return bridge.createFilmstrip(width, height);
},
// Tracker
initializeTracker(name: keyof Trackers, options: TrackerOptions): void {
bridge.initializeTracker(name, options);
},
startSession(videoUrl: string): Promise<string | null> {
return bridge.startSession(videoUrl);
},
closeSession(): void {
bridge.closeSession();
},
logAnnotations(): void {
bridge.logAnnotations();
},
createTracklet(): Promise<BaseTracklet> {
return bridge.createTracklet();
},
deleteTracklet(trackletId: number): Promise<void> {
return bridge.deleteTracklet(trackletId);
},
updatePoints(trackletId: number, points: SegmentationPoint[]): void {
bridge.updatePoints(trackletId, points);
},
clearPointsInVideo(): Promise<boolean> {
return bridge.clearPointsInVideo();
},
getWorker_ONLY_USE_WITH_CAUTION() {
return bridge.getWorker_ONLY_USE_WITH_CAUTION();
},
}),
[bridge],
);
// Handle video playback events (get playback state to main thread)
useEffect(() => {
let isPlaying = false;
function onFocus() {
// Workaround for Safari where the video frame renders black on
// unknown events. Trigger re-render frame on focus.
if (!isPlaying) {
bridge.goToFrame(bridge.frame);
}
}
function onVisibilityChange() {
// Workaround for Safari where the video frame renders black on
// visibility change hidden. Returning to visible shows a black
// frame instead of rendering the current frame.
if (document.visibilityState === 'visible' && !isPlaying) {
bridge.goToFrame(bridge.frame);
}
}
function onError(event: ErrorEvent) {
const error = event.error;
Logger.error(error);
reportError(error);
}
function onPlay() {
isPlaying = true;
setIsPlaying(true);
}
function onPause() {
isPlaying = false;
setIsPlaying(false);
}
function onStreamingDone(event: StreamingStateUpdateEvent) {
// continue to play after streaming is done (state is "full")
if (event.state === 'full') {
bridge.play();
}
}
function onLoadStart() {
setIsVideoLoading(true);
}
function onDecodeStart() {
setIsVideoLoading(false);
}
window.addEventListener('focus', onFocus);
window.addEventListener('visibilitychange', onVisibilityChange);
bridge.addEventListener('error', onError);
bridge.addEventListener('play', onPlay);
bridge.addEventListener('pause', onPause);
bridge.addEventListener('streamingStateUpdate', onStreamingDone);
bridge.addEventListener('loadstart', onLoadStart);
bridge.addEventListener('decode', onDecodeStart);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('visibilitychange', onVisibilityChange);
bridge.removeEventListener('error', onError);
bridge.removeEventListener('play', onPlay);
bridge.removeEventListener('pause', onPause);
bridge.removeEventListener('streamingStateUpdate', onStreamingDone);
bridge.removeEventListener('loadstart', onLoadStart);
bridge.removeEventListener('decode', onDecodeStart);
};
}, [bridge, reportError, setIsPlaying, setIsVideoLoading]);
return (
<div
{...stylex.props(containerStyle ?? styles.container)}
ref={resizeObserverRef}>
<div {...stylex.props(styles.canvasContainer)}>
{(isVideoLoading || loading) && <VideoLoadingOverlay />}
<canvas
ref={canvasRef}
{...stylex.props(canvasStyle)}
className="lg:rounded-[4px]"
width={width}
height={height}
style={{
height: canvasHeight,
}}
/>
</div>
{controls && (
<div {...stylex.props(styles.controls)}>
<Button
color="ghost"
size="xs"
startIcon={
isPlaying ? (
<PauseFilled
{...stylex.props(styles.controlButton)}
size={14}
/>
) : (
<PlayFilledAlt
{...stylex.props(styles.controlButton)}
size={14}
/>
)
}
onClick={() => {
isPlaying ? bridge.pause() : bridge.play();
}}
/>
</div>
)}
</div>
);
});
/**
* 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 PlaybackButton from '@/common/components/button/PlaybackButton';
import VideoFilmstrip from '@/common/components/video/filmstrip/VideoFilmstrip';
import {spacing, w} from '@/theme/tokens.stylex';
import stylex from '@stylexjs/stylex';
const styles = stylex.create({
container: {
display: 'flex',
alignItems: 'end',
gap: spacing[4],
paddingHorizontal: spacing[4],
width: '100%',
},
playbackButtonContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: w[12],
height: w[12],
},
filmstripContainer: {
flexGrow: 1,
},
});
export default function VideoFilmstripWithPlayback() {
return (
<div {...stylex.props(styles.container)}>
<div {...stylex.props(styles.playbackButtonContainer)}>
<PlaybackButton />
</div>
<div {...stylex.props(styles.filmstripContainer)}>
<VideoFilmstrip />
</div>
</div>
);
}
/**
* 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 {fontSize, fontWeight, spacing} from '@/theme/tokens.stylex';
import stylex from '@stylexjs/stylex';
import {Loading} from 'react-daisyui';
const styles = stylex.create({
overlay: {
position: 'absolute',
width: '100%',
height: '100%',
background: 'rgba(0,0,0,0.5)',
},
indicatorContainer: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
display: 'flex',
alignItems: 'center',
gap: spacing[4],
color: 'white',
},
indicatorText: {
color: 'white',
fontSize: fontSize['sm'],
fontWeight: fontWeight['medium'],
},
});
type Props = {
label?: string;
};
export default function VideoLoadingOverlay({label}: Props) {
return (
<div {...stylex.props(styles.overlay)}>
<div {...stylex.props(styles.indicatorContainer)}>
<Loading size="sm" />
<div {...stylex.props(styles.indicatorText)}>
{label ?? 'Loading video...'}
</div>
</div>
</div>
);
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment