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 SAM2DemoApp from '@/demo/SAM2DemoApp';
import SettingsContextProvider from '@/settings/SettingsContextProvider';
import {RouterProvider, createBrowserRouter} from 'react-router-dom';
export default function App() {
const router = createBrowserRouter([
{
path: '*',
element: (
<SettingsContextProvider>
<SAM2DemoApp />
</SettingsContextProvider>
),
},
]);
return <RouterProvider router={router} />;
}
/**
* 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.
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
.tab {
display: flex;
padding: 0px 0px;
margin-right: 6px;
align-items: center;
height: 100%;
}
@layer base {
@font-face {
font-family: 'Inter';
src: url(/fonts/Inter-VariableFont.ttf) format('truetype-variations');
}
}
body {
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body,
html,
#root {
height: 100%;
@media screen and (max-width: '768px') {
overflow: hidden;
}
}
:root {
--segEv-font: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
sans-serif;
--perspective: 4000px;
color-scheme: dark;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Inter', sans-serif;
}
.prose .display h1 {
@apply text-4xl text-gray-800 font-medium leading-tight;
}
.prose .display h2 {
@apply text-gray-800 font-medium leading-tight;
font-size: 2.5rem;
}
.prose h1 {
@apply text-3xl text-gray-800 font-medium leading-tight mt-2 mb-4;
letter-spacing: 0.016rem;
}
.prose h2 {
@apply text-2xl text-gray-800 font-medium leading-tight my-2;
letter-spacing: 0.01rem;
}
.prose h3 {
@apply text-xl text-gray-800 font-medium leading-tight my-2;
letter-spacing: 0.005rem;
}
.prose h4 {
@apply text-lg text-gray-800 font-medium leading-tight my-2;
}
.prose h5 {
@apply text-xl text-gray-700 font-normal leading-normal my-2;
letter-spacing: 0.005rem;
}
.prose h6 {
@apply text-base text-gray-700 font-normal leading-normal my-2;
}
.prose p {
@apply text-sm text-gray-700 font-normal leading-normal;
@apply leading-snug;
}
.prose ol,
.prose ul {
@apply text-sm text-gray-700 font-normal leading-normal;
padding-right: 2rem;
}
.dark-mode h1,
.dark-mode h2,
.dark-mode h3,
.dark-mode h4,
.dark-mode h5,
.dark-mode p,
.dark-mode ol,
.dark-mode ul,
.dark-mode p *,
.dark-mode ol *,
.dark-mode ul *,
{
@apply text-white;
}
.dark-mode h4,
.dark-mode h6,
.dark-mode li::marker,
.dark-mode a {
@apply text-gray-200;
}
.flex-grow-2 {
flex-grow: 2;
}
.flex-grow-3 {
flex-grow: 3;
}
.flex-grow-4 {
flex-grow: 4;
}
.flex-grow-5 {
flex-grow: 5;
}
.nav-title {
font-family: var(--segEv-font);
}
.segment-active {
animation: segment-highlight 2s linear infinite;
stroke-dasharray: 5, 10;
stroke-width: 4px;
}
@keyframes segment-highlight {
to {
stroke-dashoffset: 60;
}
}
.segment-select {
animation: segment-dotted 2s linear infinite;
stroke-dasharray: 3, 5;
stroke-width: 3px;
}
@keyframes segment-dotted {
to {
stroke-dashoffset: 24;
}
}
/**
* Daisy UI customizations
*/
.btn {
@apply normal-case rounded-md;
}
.comp_summary h1,
.comp_summary h2,
.comp_summary h3 {
@apply mb-4;
}
.disabled {
opacity: 0.4;
pointer-events: none;
}
.absolute-center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@screen lg {
.drawer .grid {
grid-template-columns: max-content 1fr;
}
}
.fade-in {
transition: opacity 0.5s;
opacity: 1 !important;
}
.react-photo-gallery--gallery > div {
gap: 0.25rem;
}
.sticker {
filter: drop-shadow(0.25rem 0.25rem 5px #fff)
drop-shadow(-0.25rem 0.25rem 5px #fff)
drop-shadow(0.25rem -0.25rem 5px #fff)
drop-shadow(-0.25rem -0.25rem 5px #fff);
transition: filter 0.3s ease-out;
}
.sticker:hover,
.sticker-select {
filter: drop-shadow(0.25rem 0.25rem 1px #2962d9)
drop-shadow(-0.25rem 0.25rem 1px #2962d9)
drop-shadow(0.25rem -0.25rem 1px #2962d9)
drop-shadow(-0.25rem -0.25rem 1px #2962d9);
}
/* keyframe animations */
.mask-path,
.reveal {
opacity: 0;
animation: reveal 0.4s ease-in forwards;
}
.slow-reveal {
animation: reveal 1s ease-in;
}
.reveal-then-conceal {
opacity: 0;
animation: reveal-then-conceal 1.5s ease-in-out forwards;
}
@keyframes reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes reveal-then-conceal {
from {
opacity: 0;
}
50% {
opacity: 1;
}
to {
opacity: 0;
}
}
.background-animate {
background-size: 400%;
animation: pulse 3s ease infinite;
}
@keyframes pulse {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* Fix for Safari and Mobile Safari:
Extracted Tailwind progress-bar styles and applied
them to a <div> instead of a <progress> element */
.loading-bar {
position: relative;
width: 100%;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
overflow: hidden;
height: 0.5rem;
border-radius: 1rem;
border-radius: var(--rounded-box, 1rem);
vertical-align: baseline;
background-color: hsl(var(--n) / var(--tw-bg-opacity));
--tw-bg-opacity: 0.2;
&::after {
--tw-bg-opacity: 1;
background-color: hsl(var(--n) / var(--tw-bg-opacity));
content: '';
position: absolute;
top: 0px;
bottom: 0px;
left: -40%;
width: 33.333333%;
border-radius: 1rem;
border-radius: var(--rounded-box, 1rem);
animation: loading 5s infinite ease-in-out;
}
}
@keyframes loading {
50% {
left: 107%;
}
}
@keyframes inAnimation {
0% {
opacity: 0;
max-height: 0px;
}
50% {
opacity: 1;
}
100% {
opacity: 1;
max-height: 600px;
}
}
@keyframes outAnimation {
0% {
opacity: 1;
max-height: 600px;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
max-height: 0px;
}
}
@keyframes ellipsisAnimation {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
}
.ellipsis::after {
content: '';
animation: ellipsisAnimation 1.5s infinite;
}
/**
* 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 {cloneFrame} from '@/common/codecs/WebCodecUtils';
import {FileStream} from '@/common/utils/FileUtils';
import {
createFile,
DataStream,
MP4ArrayBuffer,
MP4File,
MP4Sample,
MP4VideoTrack,
} from 'mp4box';
import {isAndroid, isChrome, isEdge, isWindows} from 'react-device-detect';
export type ImageFrame = {
bitmap: VideoFrame;
timestamp: number;
duration: number;
};
export type DecodedVideo = {
width: number;
height: number;
frames: ImageFrame[];
numFrames: number;
fps: number;
};
function decodeInternal(
identifier: string,
onReady: (mp4File: MP4File) => Promise<void>,
onProgress: (decodedVideo: DecodedVideo) => void,
): Promise<DecodedVideo> {
return new Promise((resolve, reject) => {
const imageFrames: ImageFrame[] = [];
const globalSamples: MP4Sample[] = [];
let decoder: VideoDecoder;
let track: MP4VideoTrack | null = null;
const mp4File = createFile();
mp4File.onError = reject;
mp4File.onReady = async info => {
if (info.videoTracks.length > 0) {
track = info.videoTracks[0];
} else {
// The video does not have a video track, so looking if there is an
// "otherTracks" available. Note, I couldn't find any documentation
// about "otherTracks" in WebCodecs [1], but it was available in the
// info for MP4V-ES, which isn't supported by Chrome [2].
// However, we'll still try to get the track and then throw an error
// further down in the VideoDecoder.isConfigSupported if the codec is
// not supported by the browser.
//
// [1] https://www.w3.org/TR/webcodecs/
// [2] https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_codecs#mp4v-es
track = info.otherTracks[0];
}
if (track == null) {
reject(new Error(`${identifier} does not contain a video track`));
return;
}
const timescale = track.timescale;
const edits = track.edits;
let frame_n = 0;
decoder = new VideoDecoder({
// Be careful with any await in this function. The VideoDecoder will
// not await output and continue calling it with decoded frames.
async output(inputFrame) {
if (track == null) {
reject(new Error(`${identifier} does not contain a video track`));
return;
}
const saveTrack = track;
// If the track has edits, we'll need to check that only frames are
// returned that are within the edit list. This can happen for
// trimmed videos that have not been transcoded and therefore the
// video track contains more frames than those visually rendered when
// playing back the video.
if (edits != null && edits.length > 0) {
const cts = Math.round(
(inputFrame.timestamp * timescale) / 1_000_000,
);
if (cts < edits[0].media_time) {
inputFrame.close();
return;
}
}
// Workaround for Chrome where the decoding stops at ~17 frames unless
// the VideoFrame is closed. So, the workaround here is to create a
// new VideoFrame and close the decoded VideoFrame.
// The frame has to be cloned, or otherwise some frames at the end of the
// video will be black. Note, the default VideoFrame.clone doesn't work
// and it is using a frame cloning found here:
// https://webcodecs-blogpost-demo.glitch.me/
if (
(isAndroid && isChrome) ||
(isWindows && isChrome) ||
(isWindows && isEdge)
) {
const clonedFrame = await cloneFrame(inputFrame);
inputFrame.close();
inputFrame = clonedFrame;
}
const sample = globalSamples[frame_n];
if (sample != null) {
const duration = (sample.duration * 1_000_000) / sample.timescale;
imageFrames.push({
bitmap: inputFrame,
timestamp: inputFrame.timestamp,
duration,
});
// Sort frames in order of timestamp. This is needed because Safari
// can return decoded frames out of order.
imageFrames.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
// Update progress on first frame and then every 40th frame
if (onProgress != null && frame_n % 100 === 0) {
onProgress({
width: saveTrack.track_width,
height: saveTrack.track_height,
frames: imageFrames,
numFrames: saveTrack.nb_samples,
fps:
(saveTrack.nb_samples / saveTrack.duration) *
saveTrack.timescale,
});
}
}
frame_n++;
if (saveTrack.nb_samples === frame_n) {
// Sort frames in order of timestamp. This is needed because Safari
// can return decoded frames out of order.
imageFrames.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1));
resolve({
width: saveTrack.track_width,
height: saveTrack.track_height,
frames: imageFrames,
numFrames: saveTrack.nb_samples,
fps:
(saveTrack.nb_samples / saveTrack.duration) *
saveTrack.timescale,
});
}
},
error(error) {
reject(error);
},
});
let description;
const trak = mp4File.getTrackById(track.id);
const entries = trak?.mdia?.minf?.stbl?.stsd?.entries;
if (entries == null) {
return;
}
for (const entry of entries) {
if (entry.avcC || entry.hvcC) {
const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
if (entry.avcC) {
entry.avcC.write(stream);
} else if (entry.hvcC) {
entry.hvcC.write(stream);
}
description = new Uint8Array(stream.buffer, 8); // Remove the box header.
break;
}
}
const configuration: VideoDecoderConfig = {
codec: track.codec,
codedWidth: track.track_width,
codedHeight: track.track_height,
description,
};
const supportedConfig =
await VideoDecoder.isConfigSupported(configuration);
if (supportedConfig.supported == true) {
decoder.configure(configuration);
mp4File.setExtractionOptions(track.id, null, {
nbSamples: Infinity,
});
mp4File.start();
} else {
reject(
new Error(
`Decoder config faile: config ${JSON.stringify(
supportedConfig.config,
)} is not supported`,
),
);
return;
}
};
mp4File.onSamples = async (
_id: number,
_user: unknown,
samples: MP4Sample[],
) => {
for (const sample of samples) {
globalSamples.push(sample);
decoder.decode(
new EncodedVideoChunk({
type: sample.is_sync ? 'key' : 'delta',
timestamp: (sample.cts * 1_000_000) / sample.timescale,
duration: (sample.duration * 1_000_000) / sample.timescale,
data: sample.data,
}),
);
}
await decoder.flush();
decoder.close();
};
onReady(mp4File);
});
}
export function decode(
file: File,
onProgress: (decodedVideo: DecodedVideo) => void,
): Promise<DecodedVideo> {
return decodeInternal(
file.name,
async (mp4File: MP4File) => {
const reader = new FileReader();
reader.onload = function () {
const result = this.result as MP4ArrayBuffer;
if (result != null) {
result.fileStart = 0;
mp4File.appendBuffer(result);
}
mp4File.flush();
};
reader.readAsArrayBuffer(file);
},
onProgress,
);
}
export function decodeStream(
fileStream: FileStream,
onProgress: (decodedVideo: DecodedVideo) => void,
): Promise<DecodedVideo> {
return decodeInternal(
'stream',
async (mp4File: MP4File) => {
let part = await fileStream.next();
while (part.done === false) {
const result = part.value.data.buffer as MP4ArrayBuffer;
if (result != null) {
result.fileStart = part.value.range.start;
mp4File.appendBuffer(result);
}
mp4File.flush();
part = await fileStream.next();
}
},
onProgress,
);
}
/**
* 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 {ImageFrame} from '@/common/codecs/VideoDecoder';
import {MP4ArrayBuffer, createFile} from 'mp4box';
// The selection of timescale and seconds/key-frame value are
// explained in the following docs: https://github.com/vjeux/mp4-h264-re-encode
const TIMESCALE = 90000;
const SECONDS_PER_KEY_FRAME = 2;
export function encode(
width: number,
height: number,
numFrames: number,
framesGenerator: AsyncGenerator<ImageFrame, unknown>,
progressCallback?: (progress: number) => void,
): Promise<MP4ArrayBuffer> {
return new Promise((resolve, reject) => {
let encodedFrameIndex = 0;
let nextKeyFrameTimestamp = 0;
let trackID: number | null = null;
const durations: number[] = [];
const outputFile = createFile();
const encoder = new VideoEncoder({
output(chunk, metaData) {
const uint8 = new Uint8Array(chunk.byteLength);
chunk.copyTo(uint8);
const description = metaData?.decoderConfig?.description;
if (trackID === null) {
trackID = outputFile.addTrack({
width: width,
height: height,
timescale: TIMESCALE,
avcDecoderConfigRecord: description,
});
}
const shiftedDuration = durations.shift();
if (shiftedDuration != null) {
outputFile.addSample(trackID, uint8, {
duration: getScaledDuration(shiftedDuration),
is_sync: chunk.type === 'key',
});
encodedFrameIndex++;
progressCallback?.(encodedFrameIndex / numFrames);
}
if (encodedFrameIndex === numFrames) {
resolve(outputFile.getBuffer());
}
},
error(error) {
reject(error);
return;
},
});
const setConfigurationAndEncodeFrames = async () => {
// The codec value was taken from the following implementation and seems
// reasonable for our use case for now:
// https://github.com/vjeux/mp4-h264-re-encode/blob/main/mp4box.html#L103
// Additional details about codecs can be found here:
// - https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter
// - https://www.w3.org/TR/webcodecs-codec-registry/#video-codec-registry
//
// The following setting is a good compromise between output video file
// size and quality. The latencyMode "realtime" is needed for Safari,
// which otherwise will produce 20x larger files when in quality
// latencyMode. Chrome does a really good job with file size even when
// latencyMode is set to quality.
const configuration: VideoEncoderConfig = {
codec: 'avc1.4d0034',
width: roundToNearestEven(width),
height: roundToNearestEven(height),
bitrate: 14_000_000,
alpha: 'discard',
bitrateMode: 'variable',
latencyMode: 'realtime',
};
const supportedConfig =
await VideoEncoder.isConfigSupported(configuration);
if (supportedConfig.supported === true) {
encoder.configure(configuration);
} else {
throw new Error(
`Unsupported video encoder config ${JSON.stringify(supportedConfig)}`,
);
}
for await (const frame of framesGenerator) {
const {bitmap, duration, timestamp} = frame;
durations.push(duration);
let keyFrame = false;
if (timestamp >= nextKeyFrameTimestamp) {
await encoder.flush();
keyFrame = true;
nextKeyFrameTimestamp = timestamp + SECONDS_PER_KEY_FRAME * 1e6;
}
encoder.encode(bitmap, {keyFrame});
bitmap.close();
}
await encoder.flush();
encoder.close();
};
setConfigurationAndEncodeFrames();
});
}
function getScaledDuration(rawDuration: number) {
return rawDuration / (1_000_000 / TIMESCALE);
}
function roundToNearestEven(dim: number) {
const rounded = Math.round(dim);
if (rounded % 2 === 0) {
return rounded;
} else {
return rounded + (rounded > dim ? -1 : 1);
}
}
/**
* 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.
*/
// https://github.com/w3c/webcodecs/issues/88
// https://issues.chromium.org/issues/40725065
// https://webcodecs-blogpost-demo.glitch.me/
export async function cloneFrame(frame: VideoFrame): Promise<VideoFrame> {
const {
codedHeight,
codedWidth,
colorSpace,
displayHeight,
displayWidth,
format,
timestamp,
} = frame;
const rect = {x: 0, y: 0, width: codedWidth, height: codedHeight};
const data = new ArrayBuffer(frame.allocationSize({rect}));
try {
await frame.copyTo(data, {rect});
} catch (error) {
// The VideoFrame#copyTo on x64 builds on macOS fails. The workaround here
// is to clone the frame.
// https://stackoverflow.com/questions/77898766/inconsistent-behavior-of-webcodecs-copyto-method-across-different-browsers-an
return frame.clone();
}
return new VideoFrame(data, {
codedHeight,
codedWidth,
colorSpace,
displayHeight,
displayWidth,
duration: frame.duration ?? undefined,
format: format!,
timestamp,
visibleRect: frame.visibleRect ?? undefined,
});
}
/**
* 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 ChangeVideoModal from '@/common/components/gallery/ChangeVideoModal';
import {DEMO_SHORT_NAME} from '@/demo/DemoConfig';
import {spacing} from '@/theme/tokens.stylex';
import {ImageCopy} from '@carbon/icons-react';
import stylex from '@stylexjs/stylex';
import {Button} from 'react-daisyui';
const styles = stylex.create({
container: {
position: 'relative',
backgroundColor: '#000',
padding: spacing[5],
paddingVertical: spacing[6],
display: 'flex',
flexDirection: 'column',
gap: spacing[4],
},
});
export default function MobileFirstClickBanner() {
return (
<div {...stylex.props(styles.container)}>
<div className="flex text-white text-lg">
Click an object in the video to start
</div>
<div className="text-sm text-[#A7B3BF]">
<p>
You&apos;ll be able to use {DEMO_SHORT_NAME} to make fun edits to any
video by tracking objects and applying visual effects. To start, click
any object in the video.
</p>
</div>
<div className="flex items-center">
<ChangeVideoModal
videoGalleryModalTrigger={MobileVideoGalleryModalTrigger}
showUploadInGallery={true}
/>
</div>
</div>
);
}
type MobileVideoGalleryModalTriggerProps = {
onClick: () => void;
};
function MobileVideoGalleryModalTrigger({
onClick,
}: MobileVideoGalleryModalTriggerProps) {
return (
<Button
color="ghost"
startIcon={<ImageCopy size={20} />}
onClick={onClick}
className="text-white p-0">
Change video
</Button>
);
}
/**
* 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<{
className?: string;
message: string;
position?: 'left' | 'top' | 'right' | 'bottom';
}>;
/**
* This is a custom Tooltip component because React Daisy UI does not have an
* option to *only* show tooltip on large devices.
*/
export default function Tooltip({
children,
className = '',
message,
position = 'top',
}: Props) {
return (
<div
className={`lg:tooltip tooltip-${position} ${className}`}
data-tip={message}>
{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.
*/
import useMessagesSnackbar from '@/common/components/snackbar/useDemoMessagesSnackbar';
import useVideo from '@/common/components/video/editor/useVideo';
import {activeTrackletObjectIdAtom, labelTypeAtom} from '@/demo/atoms';
import {Add} from '@carbon/icons-react';
import {useSetAtom} from 'jotai';
export default function AddObjectButton() {
const video = useVideo();
const setActiveTrackletId = useSetAtom(activeTrackletObjectIdAtom);
const setLabelType = useSetAtom(labelTypeAtom);
const {enqueueMessage} = useMessagesSnackbar();
async function addObject() {
enqueueMessage('addObjectClick');
const tracklet = await video?.createTracklet();
if (tracklet != null) {
setActiveTrackletId(tracklet.id);
setLabelType('positive');
}
}
return (
<div
onClick={addObject}
className="group flex justify-start mx-4 px-4 bg-transparent text-white !rounded-xl border-none cursor-pointer">
<div className="flex gap-6 items-center">
<div className=" group-hover:bg-graydark-700 border border-white relative h-12 w-12 md:w-20 md:h-20 shrink-0 rounded-lg flex items-center justify-center">
<Add size={36} className="group-hover:text-white text-gray-300" />
</div>
<div className="font-medium text-base">Add another object</div>
</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 useRestartSession from '@/common/components/session/useRestartSession';
import useMessagesSnackbar from '@/common/components/snackbar/useDemoMessagesSnackbar';
import useVideo from '@/common/components/video/editor/useVideo';
import {isPlayingAtom, isStreamingAtom, labelTypeAtom} from '@/demo/atoms';
import {Reset} from '@carbon/icons-react';
import stylex from '@stylexjs/stylex';
import {useAtomValue, useSetAtom} from 'jotai';
import {useState} from 'react';
import {Button, Loading} from 'react-daisyui';
const styles = stylex.create({
container: {
display: 'flex',
alignItems: 'center',
},
});
type Props = {
onRestart: () => void;
};
export default function ClearAllPointsInVideoButton({onRestart}: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const isPlaying = useAtomValue(isPlayingAtom);
const isStreaming = useAtomValue(isStreamingAtom);
const setLabelType = useSetAtom(labelTypeAtom);
const {clearMessage} = useMessagesSnackbar();
const {restartSession} = useRestartSession();
const video = useVideo();
async function handleRestart() {
if (video === null) {
return;
}
setIsLoading(true);
if (isPlaying) {
video.pause();
}
if (isStreaming) {
await video.abortStreamMasks();
}
const isSuccessful = await video.clearPointsInVideo();
if (!isSuccessful) {
await restartSession();
}
video.frame = 0;
setLabelType('positive');
onRestart();
clearMessage();
setIsLoading(false);
}
return (
<div {...stylex.props(styles.container)}>
<Button
color="ghost"
onClick={handleRestart}
className="!px-4 !rounded-full font-medium text-white hover:bg-black"
startIcon={isLoading ? <Loading size="sm" /> : <Reset size={20} />}>
Start over
</Button>
</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 PrimaryCTAButton from '@/common/components/button/PrimaryCTAButton';
import useVideo from '@/common/components/video/editor/useVideo';
import {ChevronRight} from '@carbon/icons-react';
type Props = {
onSessionClose: () => void;
};
export default function CloseSessionButton({onSessionClose}: Props) {
const video = useVideo();
function handleCloseSession() {
video?.closeSession();
video?.logAnnotations();
onSessionClose();
}
return (
<PrimaryCTAButton onClick={handleCloseSession} endIcon={<ChevronRight />}>
Good to go
</PrimaryCTAButton>
);
}
/**
* 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 ChangeVideo from '@/common/components/gallery/ChangeVideoModal';
import useMessagesSnackbar from '@/common/components/snackbar/useDemoMessagesSnackbar';
import {DEMO_SHORT_NAME} from '@/demo/DemoConfig';
import {useEffect, useRef} from 'react';
export default function FirstClickView() {
const isFirstClickMessageShown = useRef(false);
const {enqueueMessage} = useMessagesSnackbar();
useEffect(() => {
if (!isFirstClickMessageShown.current) {
isFirstClickMessageShown.current = true;
enqueueMessage('firstClick');
}
}, [enqueueMessage]);
return (
<div className="w-full h-full flex flex-col p-8">
<div className="grow flex flex-col gap-6">
<h2 className="text-2xl">Click an object in the video to start</h2>
<p className="!text-gray-60">
You&apos;ll be able to use {DEMO_SHORT_NAME} to make fun edits to any
video by tracking objects and applying visual effects.
</p>
<p className="!text-gray-60">
To start, click any object in the video.
</p>
</div>
<div className="flex items-center">
<ChangeVideo />
</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 {InformationFilled} from '@carbon/icons-react';
export default function LimitNotice() {
return (
<div className="mt-6 gap-3 mx-6 flex items-center text-gray-400">
<div>
<InformationFilled size={32} />
</div>
<div className="text-sm leading-snug">
In this demo, you can track up to 3 objects, even though the SAM 2 model
does not have a limit.
</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 ClearAllPointsInVideoButton from '@/common/components/annotations/ClearAllPointsInVideoButton';
import ObjectThumbnail from '@/common/components/annotations/ObjectThumbnail';
import {OBJECT_TOOLBAR_INDEX} from '@/common/components/toolbar/ToolbarConfig';
import {BaseTracklet} from '@/common/tracker/Tracker';
import {activeTrackletObjectIdAtom, trackletObjectsAtom} from '@/demo/atoms';
import {spacing} from '@/theme/tokens.stylex';
import stylex from '@stylexjs/stylex';
import {useAtomValue, useSetAtom} from 'jotai';
const styles = stylex.create({
container: {
display: 'flex',
padding: spacing[5],
borderTop: '1px solid #DEE3E9',
},
trackletsContainer: {
flexGrow: 1,
display: 'flex',
alignItems: 'center',
gap: spacing[5],
},
});
type Props = {
showActiveObject: () => void;
onTabChange: (newIndex: number) => void;
};
export default function MobileObjectsList({
showActiveObject,
onTabChange,
}: Props) {
const tracklets = useAtomValue(trackletObjectsAtom);
const setActiveTrackletId = useSetAtom(activeTrackletObjectIdAtom);
function handleSelectObject(tracklet: BaseTracklet) {
setActiveTrackletId(tracklet.id);
showActiveObject();
}
return (
<div {...stylex.props(styles.container)}>
<div {...stylex.props(styles.trackletsContainer)}>
{tracklets.map(tracklet => {
const {id, color, thumbnail} = tracklet;
return (
<ObjectThumbnail
key={id}
color={color}
thumbnail={thumbnail}
onClick={() => {
handleSelectObject(tracklet);
}}
/>
);
})}
</div>
<ClearAllPointsInVideoButton
onRestart={() => onTabChange(OBJECT_TOOLBAR_INDEX)}
/>
</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 MobileObjectsToolbarHeader from '@/common/components/annotations/MobileObjectsToolbarHeader';
import ObjectsToolbarBottomActions from '@/common/components/annotations/ObjectsToolbarBottomActions';
import {getObjectLabel} from '@/common/components/annotations/ObjectUtils';
import ToolbarObject from '@/common/components/annotations/ToolbarObject';
import MobileFirstClickBanner from '@/common/components/MobileFirstClickBanner';
import {activeTrackletObjectAtom, isFirstClickMadeAtom} from '@/demo/atoms';
import {useAtomValue} from 'jotai';
type Props = {
onTabChange: (newIndex: number) => void;
};
export default function MobileObjectsToolbar({onTabChange}: Props) {
const activeTracklet = useAtomValue(activeTrackletObjectAtom);
const isFirstClickMade = useAtomValue(isFirstClickMadeAtom);
if (!isFirstClickMade) {
return <MobileFirstClickBanner />;
}
return (
<div className="w-full">
<MobileObjectsToolbarHeader />
{activeTracklet != null && (
<ToolbarObject
label={getObjectLabel(activeTracklet)}
tracklet={activeTracklet}
isActive={true}
isMobile={true}
/>
)}
<ObjectsToolbarBottomActions onTabChange={onTabChange} />
</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 ToolbarProgressChip from '@/common/components/toolbar/ToolbarProgressChip';
import {isStreamingAtom, streamingStateAtom} from '@/demo/atoms';
import {useAtomValue} from 'jotai';
export default function MobileObjectsToolbarHeader() {
const isStreaming = useAtomValue(isStreamingAtom);
const streamingState = useAtomValue(streamingStateAtom);
return (
<div className="w-full flex gap-4 items-center px-5 py-5">
<div className="grow text-sm text-white">
<ToolbarProgressChip />
{streamingState === 'full'
? 'Review your selected objects across the video, and continue to edit if needed. Once everything looks good, press “Next” to continue.'
: isStreaming
? 'Watch the video closely for any places where your objects aren’t tracked correctly. You can also stop tracking to make additional edits.'
: 'Edit your object selection with a few more clicks if needed. Press “Track objects” to track your objects throughout the video.'}
</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