Merge pull request #692 from storytold/feature/webcam-acting-page

added webcam acting page
This commit is contained in:
Victor O authored and GitHub committed 2024-09-03 16:56:47 -07:00
commit 80b6f5b29b
21 files changed
+1414 -152

No files matched your search

@@ -2,7 +2,8 @@
--primary: #e66462;
--primary-light: #ea7a78;
}
html, body {
html,
body {
background-color: #1a1a27;
color: #fff;
font-family: "Source Sans 3";
@@ -13,7 +14,7 @@ html, body {
h1 {
font-size: 2.5rem;
margin-bottom: .5rem;
margin-bottom: 0.5rem;
line-height: 1em;
}
@@ -44,14 +45,14 @@ h1 {
display: flex;
flex-direction: column;
background-color: #242433;
border: 2px solid rgba(255, 255, 255, .04);
border-radius: .5rem;
border: 2px solid rgba(255, 255, 255, 0.04);
border-radius: 0.5rem;
box-sizing: border-box;
height: 330px;
padding: 2rem 1.5rem 0;
text-decoration: none;
color: #fff;
transition: .2s all;
transition: 0.2s all;
overflow: hidden;
}
@@ -62,30 +63,32 @@ h1 {
.arrow-button {
display: inline-block;
background-color: var(--primary);
border-radius: .5rem;
border-radius: 0.5rem;
height: 2.5rem;
width: 2.5rem;
transition: .2s all;
transition: 0.2s all;
}
.tools-grid-item:hover .arrow-button {
background-color: var(--primary-light);
}
.arrow-button line, .arrow-button polyline {
.arrow-button line,
.arrow-button polyline {
stroke: #fff;
stroke-width: 2.5px;
stroke-linecap: round;
fill: none;
transition: .2s all;
transition: 0.2s all;
}
.tools-grid-item:hover line, .tools-grid-item:hover polyline {
.tools-grid-item:hover line,
.tools-grid-item:hover polyline {
transform: translateX(3px);
}
.hanashi-banner {
transition: .2s all ease-in-out;
transition: 0.2s all ease-in-out;
}
.tools-grid-item:hover .hanashi-banner {
@@ -95,11 +98,11 @@ h1 {
.banner-art {
width: 100%;
height: 100%;
border-radius: .5rem .5rem 0 0;
border-radius: 0.5rem 0.5rem 0 0;
overflow: hidden;
}
.banner-art img {
width: 100%;
height: auto;
}
}
@@ -0,0 +1,50 @@
@import "scss/colours.scss";
@import "scss/site-refresh.scss";
.fy-camera-input {
// background: red;
border: lighten($panel, 6%) solid 2px;
// height: 100%;
width: 100%;
aspect-ratio: 1;
position: relative;
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
.fy-camera-input-preview {
// background: red;
position: absolute;
width: 100%;
height: 100%;
margin-bottom: 0;
top: 0;
// margin: ;
padding: 0;
video {
height: 100%;
}
.fy-camera-input-float-btn {
background-color: rgba($primary, 0.85);
backdrop-filter: blur(5px);
position: absolute;
top: 0.5rem;
margin: auto;
left: 0.5rem;
right: 0.5rem;
}
}
.fy-camera-input-toggle-container {
@include video-controls;
border-radius: 0.5rem;
padding: 0.5rem 0.5rem 0.25rem;
left: 0;
right: 0;
margin: auto;
bottom: 0.5rem;
position: absolute;
}
}
@@ -0,0 +1,130 @@
import React, { useState } from "react";
import Webcam from "react-webcam";
import { a, TransitionFn, useTransition } from "@react-spring/web";
import { useCameraState, useMedia, useModal } from "hooks";
import { CameraCapture } from "components/modals";
import { Button, RecordToggle } from "components/common";
import "./CameraInput.scss";
export interface CameraInputEvent {
target: {
value: string;
};
}
interface CameraInputProps {
GApage?: string;
onChange?: (e: CameraInputEvent) => void;
value?: any;
}
export default function CameraInput({
GApage = "/",
onChange,
value,
}: CameraInputProps) {
const { open } = useModal();
const camera = useCameraState(true);
const [token, tokenSet] = useState("");
const { bucketUrl } = useMedia({ mediaToken: token });
const cameraClick = () =>
open({
component: CameraCapture,
padding: false,
props: {
autoCapture: true,
camera,
GApage,
selectToken: (token: string) => {
if (onChange) {
tokenSet(token);
onChange({ target: { value: token } });
}
},
},
width: "square",
});
const transitions: TransitionFn<
boolean,
{ opacity: number; transform: string }
> = useTransition(!!token, {
config: { mass: 1, tension: 80, friction: 10 },
from: { opacity: 0, transform: `translateX(${5}rem)` },
enter: { opacity: 1, transform: `translateX(0)` },
leave: { opacity: 0, transform: `translateX(${5}rem)` },
});
const reset = () => {
cameraClick();
tokenSet("");
};
return (
<div {...{ className: "fy-camera-input", onClick: cameraClick }}>
{transitions((style: any, hasToken: boolean) =>
hasToken ? (
<a.div
{...{
className: "fy-camera-input-preview",
style,
}}
>
<video
controls
{...{
src: bucketUrl,
}}
/>
<Button
{...{
className: "fy-camera-input-float-btn",
label: "Record again",
onClick: reset,
small: true,
}}
/>
</a.div>
) : (
<a.div
{...{
className: "fy-camera-input-preview",
style,
}}
>
<Webcam
audio
{...{
muted: true,
// onUserMedia: () => cameraStartedSet(true),
ref: camera.ref,
videoConstraints: {
width: 512,
height: 512,
facingMode: "user",
// facingMode:
// cameraPosition === "user"
// ? cameraPosition
// : { exact: cameraPosition },
},
}}
/>
<div
{...{
className: "fy-camera-input-toggle-container",
}}
>
<RecordToggle
{...{
className: "fy-camera-input-toggle",
value: false,
}}
/>
</div>
</a.div>
)
)}
</div>
);
}
@@ -0,0 +1,2 @@
export { default } from "./CameraInput";
export type { CameraInputEvent } from "./CameraInput";
@@ -0,0 +1,30 @@
import React from "react";
interface Props {
className?: string;
description: string;
stepNumber: number;
title: string;
}
export default function InputDescription({
className,
description,
stepNumber,
title,
}: Props) {
return (
<div
{...{
className: `fy-input-description${className ? " " + className : ""}`,
}}
>
<div className="d-flex gap-2 align-items-center mb-1">
{stepNumber && <div className="lp-step">{stepNumber}</div>}
<h2 className="fs-5 mb-0 fw-semibold">{title}</h2>
</div>
<p className="fw-medium fs-7 opacity-75">{description}</p>
</div>
);
}
@@ -0,0 +1 @@
export { default } from "./InputDescription";
@@ -0,0 +1,68 @@
@import "scss/colours.scss";
.fy-record-toggle {
appearance: none;
border: none;
background: none;
display: inline-flex;
justify-content: center;
flex-direction: column;
align-items: center;
color: #fff;
font-size: 0.75rem;
* {
pointer-events: none;
}
svg {
display: block;
height: 2rem;
width: 2rem;
margin-bottom: 0.25rem;
&.fy-record-toggle-record {
circle {
stroke: $primary;
}
rect {
fill: $primary;
}
&:hover {
circle {
stroke: $primary-light;
}
rect {
fill: $primary-light;
}
}
}
&.fy-record-toggle-stop {
circle {
stroke: $secondary-lightest;
}
rect {
fill: $secondary-lightest;
}
&:hover {
circle {
stroke: #fff;
}
rect {
fill: #fff;
}
}
}
circle,
rect {
transition: all 0.25s ease-in-out;
}
circle {
fill: none;
stroke-width: 2px;
}
}
}
@@ -0,0 +1,87 @@
import React from "react";
import { a, useSpring } from "@react-spring/web";
import "./RecordToggle.scss";
export interface RecordToggleEvent {
target: {
value: boolean;
};
}
export interface RecordToggleProps {
className?: string;
counter?: number;
onChange?: (e: RecordToggleEvent) => void;
value: boolean;
}
export default function RecordToggle({
className,
counter = 0,
onChange,
value,
}: RecordToggleProps) {
const hours = Math.floor(counter / 3600);
const minutes = Math.floor((counter - hours * 3600) / 60);
const seconds = counter - hours * 3600 - minutes * 60;
const timeString =
hours.toString().padStart(2, "0") +
":" +
minutes.toString().padStart(2, "0") +
":" +
seconds.toString().padStart(2, "0");
const mainToggleStyle = useSpring({
rx: value ? 1 : 8,
size: value ? 14 : 16,
xy: value ? 9 : 8,
});
const onClick = () => {
if (onChange) {
onChange({
target: {
value: !value,
},
});
}
};
return (
<button
{...{
className: `fy-record-toggle${className ? " " + className : ""}`,
onClick,
abc: 0,
capturing: true,
}}
>
<svg
{...{
className: value
? "fy-record-toggle-stop"
: "fy-record-toggle-record",
}}
>
<circle
{...{
cx: 16,
cy: 16,
r: 15,
}}
/>
<a.rect
{...{
x: mainToggleStyle.xy,
y: mainToggleStyle.xy,
height: mainToggleStyle.size,
width: mainToggleStyle.size,
rx: mainToggleStyle.rx,
}}
/>
</svg>
{value ? timeString : "Record"}
</button>
);
}
@@ -0,0 +1,2 @@
export { default } from "./RecordToggle";
export type { RecordToggleEvent, RecordToggleProps } from "./RecordToggle";
@@ -7,6 +7,7 @@ import BasicTabs from "./BasicTabs";
import BookmarkButton from "./BookmarkButton";
import Button from "./Button";
import ButtonRevertToDefault from "./ButtonRevertToDefault";
import CameraInput, { CameraInputEvent } from "./CameraInput";
import Checkbox from "./Checkbox";
import { MediaCards, SkeletonCard, WeightsCards } from "./Card";
import Container from "./Container";
@@ -21,6 +22,7 @@ import FileLabel from "./FileLabel";
import ImageInput from "./ImageInput";
import Input from "./Input";
import InputSeed from "./InputSeed";
import InputDescription from "./InputDescription";
import JobsClearButton from "./JobsClearButton";
import JobQueueTicker from "./JobQueueTicker";
import Label from "./Label";
@@ -35,6 +37,10 @@ import NumberSliderV2 from "./NumberSliderV2";
import Pagination from "./Pagination";
import Panel from "./Panel";
import Radio from "./Radio";
import RecordToggle, {
RecordToggleEvent,
RecordToggleProps,
} from "./RecordToggle";
import Scene3D from "./Scene3D/Scene3D";
import Searcher from "./Searcher";
import SegmentButtons from "./SegmentButtons";
@@ -72,6 +78,7 @@ export {
BasicTabs,
BookmarkButton,
Button,
CameraInput,
//Cards
DeviceNotSupported,
DropdownOptions,
@@ -90,6 +97,7 @@ export {
FileLabel,
ImageInput,
Input,
InputDescription,
InputSeed,
JobsClearButton,
JobQueueTicker,
@@ -106,6 +114,7 @@ export {
Pagination,
Panel,
Radio,
RecordToggle,
Scene3D,
SegmentButtons,
Searcher,
@@ -137,7 +146,10 @@ export {
export type {
ActionButtonProps,
CameraInputEvent,
ModalUtilities,
RecordToggleEvent,
RecordToggleProps,
SelectModalData,
Socials,
ZoomSliderOnChangeEvent,
@@ -1,6 +1,7 @@
import React from "react";
import { SlideProps } from "./EntityInput";
import {
useCameraState,
// useMediaUploader,
useModal,
useSession,
@@ -53,17 +54,19 @@ export default function EntityInputEmpty({
}: EmptySlideProps) {
const { open } = useModal();
const { user } = useSession();
const camera = useCameraState();
const accepted = Array.isArray(accept) ? accept : [accept];
const inputMode = EntityInputMode[type];
const isMedia = inputMode === EntityInputMode.media;
const fileTypes = isMedia
? accepted
.map((mediaCategory, i) => {
return mediaCategory
? getMediaTypesByCategory(mediaCategoryfromString(mediaCategory))
: [];
})
.flat()
.map((mediaCategory, i) => {
return mediaCategory
? getMediaTypesByCategory(mediaCategoryfromString(mediaCategory))
: [];
})
.flat()
: [];
const props: MediaBrowserProps = {
@@ -84,8 +87,9 @@ export default function EntityInputEmpty({
component: CameraCapture,
padding: false,
props: {
camera,
GApage,
selectToken
selectToken,
},
width: "square",
});
@@ -99,8 +103,9 @@ export default function EntityInputEmpty({
return faFile;
};
const supported = `${fileTypes.length ? fileTypes.join(", ") : fileTypes[0]
} files supported`;
const supported = `${
fileTypes.length ? fileTypes.join(", ") : fileTypes[0]
} files supported`;
return (
<>
@@ -79,7 +79,7 @@ export default function AITools() {
imgAlt: "Video Compositor",
},
{
to: "/beta/webcam-acting",
to: "/webcam-acting",
title: "Webcam Acting",
text: "Act as your character through your camera",
videoSrc: "/videos/ai-tools/ca_video.mp4",
@@ -28,6 +28,10 @@
position: absolute;
width: 100%;
height: 100%;
video {
height: 100%;
}
}
.fy-camera-capture-centered {
@@ -1,12 +1,13 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useState } from "react";
import Webcam from "react-webcam";
import { a, TransitionFn, useSpring, useTransition } from "@react-spring/web";
import { a, TransitionFn, useTransition } from "@react-spring/web";
import { WorkIndicator } from "components/svg";
import { useInterval, useMediaUploader } from "hooks";
import { CameraState, useCameraState, useMediaUploader } from "hooks";
import { UploaderResponse } from "components/entities/EntityTypes";
import {
Button,
ModalUtilities,
RecordToggle,
Spinner,
SegmentButtons,
} from "components/common";
@@ -16,80 +17,21 @@ import CapturePreview from "./CapturePreview";
import "./CameraCapture.scss";
interface CameraCaptureProps extends ModalUtilities {
autoCapture?: boolean;
camera: CameraState;
GApage?: string;
selectToken: (token: string) => void;
}
export default function CameraCapture({
autoCapture,
handleClose,
GApage,
selectToken,
}: CameraCaptureProps) {
const webcamRef = useRef<Webcam>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const [capturing, capturingSet] = useState(false);
const [recordedChunks, recordedChunksSet] = useState([]);
const [counter, counterSet] = useState(0);
const [blob, blobSet] = useState<Blob | null>(null);
const [cameraStarted, cameraStartedSet] = useState(false);
const [cameraPosition, cameraPositionSet] = useState("user");
const hours = Math.floor(counter / 3600);
const minutes = Math.floor((counter - hours * 3600) / 60);
const seconds = counter - hours * 3600 - minutes * 60;
const timeString =
hours.toString().padStart(2, "0") +
":" +
minutes.toString().padStart(2, "0") +
":" +
seconds.toString().padStart(2, "0");
const handleDataAvailable = useCallback(
({ data }) => {
if (data.size > 0) {
recordedChunksSet(prev => prev.concat(data));
}
},
[recordedChunksSet]
);
const mainToggleClick = () => {
if (!capturing) {
capturingSet(true);
if (webcamRef.current && webcamRef.current.stream) {
mediaRecorderRef.current = new MediaRecorder(webcamRef.current.stream, {
mimeType: "video/mp4",
});
mediaRecorderRef.current.addEventListener(
"dataavailable",
handleDataAvailable
);
mediaRecorderRef.current.start();
}
} else {
capturingSet(false);
if (mediaRecorderRef.current !== null) {
mediaRecorderRef.current.stop();
}
}
};
const mainToggleStyle = useSpring({
rx: capturing ? 1 : 8,
size: capturing ? 14 : 16,
xy: capturing ? 9 : 8,
});
const resetCapture = () => {
cameraStartedSet(false);
blobSet(null);
recordedChunksSet([]);
counterSet(0);
};
const camera = useCameraState(autoCapture);
const {
busy: uploaderBusy,
@@ -100,9 +42,9 @@ export default function CameraCapture({
onError: () => {
// @ts-ignore
window.dataLayer.push({
"event": "upload_failure_webcam",
"page": GApage || "/",
"user_id": "$user_id"
event: "upload_failure_webcam",
page: GApage || "/",
user_id: "$user_id",
});
},
onSuccess: (res: UploaderResponse) => {
@@ -115,13 +57,15 @@ export default function CameraCapture({
? 3
: uploaderBusy
? 2
: blob && !capturing
: camera.blob && !camera.capturing
? 1
: 0;
const upload = () => {
if (blob) {
const file = new File([blob], "test-upload.mp4", { type: "video/mp4" });
if (camera.blob) {
const file = new File([camera.blob], "test-upload.mp4", {
type: "video/mp4",
});
createUpload(file);
}
};
@@ -141,27 +85,6 @@ export default function CameraCapture({
{ label: "Rear camera", value: "enviroment" },
];
useInterval({
eventProps: { capturing, counter },
interval: 1000,
locked: !capturing,
onTick: () => {
if (capturing) {
counterSet(currentCounter => currentCounter + 1);
}
},
});
useEffect(() => {
if (!blob && !capturing && recordedChunks.length) {
blobSet(
new Blob(recordedChunks, {
type: "video/mp4",
})
);
}
}, [blob, capturing, recordedChunks]);
return (
<div
{...{
@@ -184,8 +107,8 @@ export default function CameraCapture({
{...{
muted: true,
className: "fy-camera-capture-display",
onUserMedia: () => cameraStartedSet(true),
ref: webcamRef,
onUserMedia: () => camera.startedSet(true),
ref: camera.ref,
videoConstraints: {
width: 512,
height: 512,
@@ -196,40 +119,16 @@ export default function CameraCapture({
},
}}
/>
{cameraStarted ? (
{camera.started ? (
<div {...{ className: "fy-camera-capture-controls" }}>
<button
<RecordToggle
{...{
className: "fy-camera-capture-record-toggle",
onClick: mainToggleClick,
className: "fy-camera-input-toggle",
counter: camera.counter,
value: camera.capturing,
onChange: camera.toggle,
}}
>
<svg
{...{
className: capturing
? "fy-camera-capture-btn-stop"
: "fy-camera-capture-btn-record",
}}
>
<circle
{...{
cx: 16,
cy: 16,
r: 15,
}}
/>
<a.rect
{...{
x: mainToggleStyle.xy,
y: mainToggleStyle.xy,
height: mainToggleStyle.size,
width: mainToggleStyle.size,
rx: mainToggleStyle.rx,
}}
/>
</svg>
{capturing ? timeString : "Record"}
</button>
/>
{isMobile && (
<SegmentButtons
{...{
@@ -250,8 +149,8 @@ export default function CameraCapture({
<CapturePreview
{...{
blob,
resetCapture,
blob: camera.blob,
resetCapture: camera.reset,
style,
upload,
}}
@@ -2,8 +2,12 @@ import useAnimationStatus, {
AnimationEvents,
AnimationStatus,
} from "./useAnimationStatus";
import useBatchContent, { BatchInputProps, MakeBatchPropsParams } from "./useBatchContent";
import useBatchContent, {
BatchInputProps,
MakeBatchPropsParams,
} from "./useBatchContent";
import useBookmarks, { MakeBookmarksProps } from "./useBookmarks";
import useCameraState, { CameraState } from "./useCameraState";
import useChanger from "./useChanger";
import useCoverImgUpload from "./useCoverImgUpload";
import useDebounce from "./useDebounce";
@@ -41,6 +45,7 @@ export {
useAnimationStatus,
useBatchContent,
useBookmarks,
useCameraState,
useChanger,
useCoverImgUpload,
useDebounce,
@@ -77,6 +82,7 @@ export {
export type {
AnimationEvents,
BatchInputProps,
CameraState,
MakeBookmarksProps,
MakeBatchPropsParams,
MakeRatingsProps,
@@ -0,0 +1,116 @@
import { RefObject, useCallback, useEffect, useRef, useState } from "react";
import Webcam from "react-webcam";
import { useInterval } from "hooks";
import { RecordToggleEvent } from "components/common";
type CameraBlob = Blob | null;
export interface CameraState {
blob: CameraBlob;
capturing: boolean;
counter: number;
ref: RefObject<Webcam>;
reset: () => void;
started: boolean;
startedSet: (startedState: boolean) => void;
toggle: (e: RecordToggleEvent) => void;
}
export default function useCameraState(autoCapture = false): CameraState {
const ref = useRef<Webcam>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const [blob, blobSet] = useState<CameraBlob>(null);
const [started, startedSet] = useState(false);
const [counter, counterSet] = useState(0);
const [capturing, capturingSet] = useState(false);
const [recordedChunks, recordedChunksSet] = useState([]);
const [autoTriggered, autoTriggeredSet] = useState(false);
// useEffect will only update when watching the stream value directly
const stream = ref?.current?.stream;
const handleDataAvailable = useCallback(
({ data }) => {
if (data.size > 0) {
recordedChunksSet(prev => prev.concat(data));
}
},
[recordedChunksSet]
);
const startCapture = useCallback(
current => {
capturingSet(true);
if (current && current.stream) {
mediaRecorderRef.current = new MediaRecorder(current.stream, {
mimeType: "video/mp4",
});
mediaRecorderRef.current.addEventListener(
"dataavailable",
handleDataAvailable
);
mediaRecorderRef.current.start();
}
},
[handleDataAvailable]
);
const toggle = ({ target }: RecordToggleEvent) => {
if (target.value && ref.current) {
startCapture(ref.current);
} else {
capturingSet(false);
if (mediaRecorderRef.current !== null) {
mediaRecorderRef.current.stop();
}
}
};
const reset = () => {
startedSet(false);
blobSet(null);
recordedChunksSet([]);
counterSet(0);
};
useInterval({
eventProps: { capturing, counter },
interval: 1000,
locked: !capturing,
onTick: () => {
if (capturing) {
counterSet(currentCounter => currentCounter + 1);
}
},
});
useEffect(() => {
if (!blob && !capturing && recordedChunks.length) {
blobSet(
new Blob(recordedChunks, {
type: "video/mp4",
})
);
}
if (autoCapture && !capturing && !autoTriggered && ref.current && stream) {
autoTriggeredSet(true);
startCapture(ref.current);
}
}, [
autoCapture,
autoTriggered,
blob,
capturing,
recordedChunks,
startCapture,
stream,
]);
return { blob, capturing, counter, ref, reset, started, startedSet, toggle };
}
@@ -9,6 +9,7 @@ import {
} from "@storyteller/components/src/api/prompts/GetPrompts";
import { DeleteMedia } from "@storyteller/components/src/api/media_files/DeleteMedia";
import { FetchStatus } from "@storyteller/components/src/api/_common/SharedFetchTypes";
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
export default function useMedia({
debug = "",
@@ -41,6 +42,12 @@ export default function useMedia({
status === FetchStatus.in_progress ||
writeStatus === FetchStatus.in_progress;
// const bucketConfig = new BucketConfig();
const bucketUrl = media?.public_bucket_path
? new BucketConfig().getGcsUrl(media.public_bucket_path)
: "";
useEffect(() => {
// this condidition handles all media file fetches
// it is triggered when status is ready and there is a media token but no media
@@ -80,6 +87,7 @@ export default function useMedia({
return {
busy,
bucketUrl,
media,
mediaFile: media,
mediaSet,
@@ -63,6 +63,22 @@ styles with important tags:
backdrop-filter: blur(5px);
}
@mixin video-controls {
@include video-tint;
position: absolute;
bottom: 1rem;
border-radius: 0.5rem;
padding: 0.5rem 0.5rem 0.25rem;
left: 0;
right: 0;
margin: auto;
width: 50%;
display: flex;
justify-content: center;
gap: 1rem;
align-items: center;
}
.fakeyou-refresh {
//- BS5 classnames -//
.object-fit-cover {
@@ -132,6 +132,7 @@ import SignUpSuccessPage from "./pages/beta_key/SignUpSuccessPage";
import { StudioMobileCheckPage } from "./pages/landing/storyteller/PostlaunchLanding/StudioMobileCheckPage";
import { UploadNewTtsModelPage } from "./pages/upload/UploadNewTtsModelPage";
import LivePortrait from "./pages/live_portrait/LivePortrait";
import CameraLivePortrait from "./pages/live_portrait/CameraLivePortrait";
import { BetaLipSyncForm } from "./pages/beta_products/BetaLipSyncForm";
import { BetaLipSyncPage } from "./pages/beta_products/BetaLipSyncPage";
import { ToolsTestPage } from "./pages/tools_test/ToolsTestPage";
@@ -762,6 +763,10 @@ class PageContainer extends React.Component<
/>
</Route>
<Route exact path="/webcam-acting">
<CameraLivePortrait />
</Route>
{/* Route for initial voice creation */}
<Route exact path="/voice-designer/create">
<VoiceDesignerFormPage
@@ -0,0 +1,26 @@
@import "scss/_breakpoint.scss";
@import "scss/breakpoints.scss";
.fy-cam-lp-column-set {
display: grid;
gap: 1rem;
@include breakpoint($over992) {
grid-template-columns: 1fr 1fr;
}
.fy-cam-lp-camera-section {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
@include breakpoint($under992) {
.fy-input-description {
order: 1;
}
.fy-camera-input {
order: 2;
}
}
}
}
@@ -0,0 +1,792 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Button,
CameraInput,
CameraInputEvent,
Checkbox,
Container,
InputDescription,
Label,
Panel,
} from "components/common";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faImageUser,
faLock,
faSparkles,
faStars,
} from "@fortawesome/pro-solid-svg-icons";
import "./CameraLivePortrait.scss";
import "./LivePortrait.scss";
import ThumbnailMediaPicker from "./ThumbnailMediaPicker";
import {
EnqueueFaceMirror,
EnqueueFaceMirrorResponse,
} from "@storyteller/components/src/api/workflows/EnqueueFaceMirror";
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
import { v4 as uuidv4 } from "uuid";
import { useInferenceJobs, useModal, useSession } from "hooks";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { AITools } from "components/marketing";
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
import SessionLpInferenceResultsList from "./SessionLpInferenceResultsList";
import { GetMedia } from "@storyteller/components/src/api/media_files/GetMedia";
import { useLocation } from "react-router-dom";
import { LivePortraitDetails } from "@storyteller/components/src/api/model_inference/GetModelInferenceJobStatus";
import { useDocumentTitle } from "@storyteller/components/src/hooks/UseDocumentTitle";
import SourceEntityInput from "./SourceEntityInput";
import { useHistory } from "react-router-dom";
import { JobState } from "@storyteller/components/src/jobs/JobStates";
interface GeneratedVideo {
sourceIndex: number;
motionIndex: number;
sourceToken: string;
motionToken: string;
videoSrc: string;
jobToken: string;
createdAt: Date;
}
interface CurrentlyGenerating {
sourceIndex: number;
motionIndex: number;
jobState?: JobState;
}
interface JobProgress {
[key: string]: number | null;
}
const PRECOMPUTED_SOURCE_TOKENS: string[] = [
"m_2xrse9799wvy8hkv8tbxqxct8089t7", // Mona Lisa
"m_pt99cdgcanv1m8yejdr3yzxyv5jmps", // Wednesday
"m_780cd9zhc5tznwcc2d8tnrqgs5dwh7", // Shiba
"m_mfstb9ac7x657eyb0pbw6ybmfxh25s", // Link
];
const PRECOMPUTED_DRIVER_TOKENS: string[] = [
"m_z278r5b1r2279xqkxszxjkqhc1dg2g", // Awkward Smile
"m_dv9pcmmwdpgyevyxsyxcahkhd2c839", // Dance Monkey
"m_53j0kfaesw4jem4713tttk6142sd0y", // Split
"m_ar300kqxy3ez8znq9p40y2qejfhsc2", // Slight Smile
];
export default function LivePortrait() {
useDocumentTitle("Live Portrait AI. Free Video Animation");
const { enqueueInferenceJob } = useInferenceJobs();
const { loggedIn, loggedInOrModal, sessionFetched, sessionSubscriptions } =
useSession();
const { open, close } = useModal();
const [isEnqueuing, setIsEnqueuing] = useState(false);
const [selectedSourceIndex, setSelectedSourceIndex] = useState(0);
const [selectedMotionIndex, setSelectedMotionIndex] = useState(0);
const [removeWatermark, setRemoveWatermark] = useState(false);
const [visibility, setVisibility] = useState<"private" | "public">("public");
const hasPremium = sessionSubscriptions?.hasPaidFeatures();
const [generatedVideoSrc, setGeneratedVideoSrc] = useState("");
const [sourceTokens, setSourceTokens] = useState<string[]>([
...PRECOMPUTED_SOURCE_TOKENS,
]);
const [motionTokens, setMotionTokens] = useState<string[]>([
...PRECOMPUTED_DRIVER_TOKENS,
]);
const numberOfInitialSourceTokensRef = useRef(sourceTokens.length);
const numberOfInitialSourceTokens = numberOfInitialSourceTokensRef.current;
const numberOfInitialMotionTokensRef = useRef(motionTokens.length);
const numberOfInitialMotionTokens = numberOfInitialMotionTokensRef.current;
const [userSourceToken, setUserSourceToken] = useState<string>("");
const [userMotionToken, setUserMotionToken] = useState<string>("");
const [isUserUploaded, setIsUserUploaded] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [jobProcessedTokens, setJobProcessedTokens] = useState<string[]>([]);
const [currentlyGeneratingList, setCurrentlyGeneratingList] = useState<
CurrentlyGenerating[]
>([]);
const [jobProgress, setJobProgress] = useState<JobProgress>({});
const [currentCombinationKey, setCurrentCombinationKey] = useState("");
const getCombinationKey = (sourceIndex: number, motionIndex: number) =>
`s${sourceIndex}_m${motionIndex}`;
const [cameraInputToken, cameraInputTokenSet] = useState("");
const location = useLocation();
const history = useHistory();
const handleJobClick = (job: InferenceJob) => {
const livePortraitDetails = job.maybeLivePortraitDetails;
if (livePortraitDetails) {
const { source_media_file_token, face_driver_media_file_token } =
livePortraitDetails;
const sourceIndex = sourceTokens.indexOf(source_media_file_token);
const motionIndex = motionTokens.indexOf(face_driver_media_file_token);
setSelectedSourceIndex(sourceIndex);
setSelectedMotionIndex(motionIndex);
setCurrentCombinationKey(getCombinationKey(sourceIndex, motionIndex));
// Find the video for the clicked job
const videoFromJob = generatedVideos.find(
video => video.jobToken === job.jobToken
);
if (videoFromJob) {
setGeneratedVideoSrc(videoFromJob.videoSrc);
setIsGenerating(false);
} else {
// If there's no video and the job is still in the generating list, show progress
const isGenerating = currentlyGeneratingList.some(
gen =>
gen.sourceIndex === sourceIndex && gen.motionIndex === motionIndex
);
if (isGenerating) {
const progress =
jobProgress[getCombinationKey(sourceIndex, motionIndex)] || 0;
setJobProgress({
...jobProgress,
[getCombinationKey(sourceIndex, motionIndex)]: progress,
});
setIsGenerating(true);
} else {
setIsGenerating(false);
setGeneratedVideoSrc("");
}
}
}
};
const handleJobStateChange = useCallback(
(jobToken: string, jobState: JobState) => {
const currentCombinationKey = getCombinationKey(
selectedSourceIndex,
selectedMotionIndex
);
if (jobState === JobState.COMPLETE_FAILURE) {
setCurrentlyGeneratingList(prevList =>
prevList.filter(
gen =>
!(
gen.sourceIndex === selectedSourceIndex &&
gen.motionIndex === selectedMotionIndex
)
)
);
setJobProgress(prevProgress => {
const updatedProgress = { ...prevProgress };
delete updatedProgress[currentCombinationKey];
return updatedProgress;
});
setIsGenerating(false);
}
},
[selectedSourceIndex, selectedMotionIndex]
);
const handleJobProgress = (progress: number | null) => {
setJobProgress(prevProgress => ({
...prevProgress,
[currentCombinationKey]: progress,
}));
};
const handleSourceSelect = (index: number) => {
setIsUserUploaded(index >= numberOfInitialSourceTokens);
setSelectedSourceIndex(index);
setCurrentCombinationKey(getCombinationKey(index, selectedMotionIndex));
};
const enqueueClick = () => {
if (
!loggedInOrModal({
loginMessage: "Login to finish animating your portrait",
signupMessage: "Sign up to finish animating your portrait",
})
) {
return;
}
// Clear the generated video when reanimating
setGeneratedVideoSrc("");
setIsGenerating(true);
// Add the current source and motion combination to the generating list
setCurrentlyGeneratingList(prevList => [
...prevList,
{ sourceIndex: selectedSourceIndex, motionIndex: selectedMotionIndex },
]);
setIsEnqueuing(true);
const combinationKey = getCombinationKey(
selectedSourceIndex,
selectedMotionIndex
);
setCurrentCombinationKey(combinationKey);
EnqueueFaceMirror("", {
creator_set_visibility: visibility,
face_driver_media_file_token: cameraInputToken,
maybe_crop: {
height: 0,
width: 0,
x: 0,
y: 0,
},
remove_watermark: removeWatermark,
source_media_file_token: sourceTokens[selectedSourceIndex],
uuid_idempotency_token: uuidv4(),
}).then((res: EnqueueFaceMirrorResponse) => {
if (res.success && res.inference_job_token) {
enqueueInferenceJob(
res.inference_job_token,
FrontendInferenceJobType.LivePortrait,
false
);
} else {
// @ts-ignore
window.dataLayer.push({
event: "enqueue_failure",
page: "/webcam-acting",
user_id: "$user_id",
});
console.error("Failed to enqueue job", res);
setIsGenerating(false);
// Remove the combination from currentlyGeneratingList if fail
setCurrentlyGeneratingList(prevList =>
prevList.filter(
gen =>
gen.sourceIndex !== selectedSourceIndex ||
gen.motionIndex !== selectedMotionIndex
)
);
}
setIsEnqueuing(false);
});
};
useEffect(() => {
if (userSourceToken) {
setSourceTokens(prevTokens => {
const tokenIndex = prevTokens.indexOf(userSourceToken);
if (tokenIndex !== -1) {
setSelectedSourceIndex(tokenIndex);
setIsUserUploaded(tokenIndex >= numberOfInitialSourceTokens);
return prevTokens;
} else {
const updatedTokens = [...prevTokens, userSourceToken];
setSelectedSourceIndex(updatedTokens.length - 1);
setIsUserUploaded(true);
return updatedTokens;
}
});
setUserSourceToken("");
}
}, [userSourceToken, numberOfInitialSourceTokens]);
useEffect(() => {
if (userMotionToken) {
setMotionTokens(prevTokens => {
const tokenIndex = prevTokens.indexOf(userMotionToken);
if (tokenIndex !== -1) {
setSelectedMotionIndex(tokenIndex);
setIsUserUploaded(tokenIndex >= numberOfInitialMotionTokens);
return prevTokens;
} else {
const updatedTokens = [...prevTokens, userMotionToken];
setSelectedMotionIndex(updatedTokens.length - 1);
setIsUserUploaded(true);
return updatedTokens;
}
});
setUserMotionToken("");
}
}, [userMotionToken, numberOfInitialMotionTokens]);
const handleOpenUploadSourceModal = () => {
open({
component: SourceEntityInput,
props: {
onChange: ({ target }: { target: any }) => {
setUserSourceToken(target.value);
close();
},
},
width: "small",
});
};
const [, setSelectedSourceMedia] = useState<{
[key: string]: any;
}>({});
const handleSelectedMediaChange = (media: any) => {
setSelectedSourceMedia(media);
};
const handleJobTokens = async (
maybeResultToken: string,
jobToken: string,
createdAt: Date,
livePortraitDetails?: LivePortraitDetails
) => {
if (!livePortraitDetails) {
return;
}
// makes sure that it only processes each job once and exactly when needed
if (jobProcessedTokens.includes(jobToken)) {
return;
}
const response = await GetMedia(maybeResultToken, {});
if (
response &&
response.media_file &&
response.media_file.public_bucket_path
) {
const mediaLink = new BucketConfig().getGcsUrl(
response.media_file.public_bucket_path
);
const sourceIndex = sourceTokens.indexOf(
livePortraitDetails.source_media_file_token
);
const motionIndex = motionTokens.indexOf(
livePortraitDetails.face_driver_media_file_token
);
const newGeneratedVideo = {
sourceIndex,
motionIndex,
sourceToken: livePortraitDetails.source_media_file_token,
motionToken: livePortraitDetails.face_driver_media_file_token,
videoSrc: mediaLink,
jobToken,
createdAt,
};
setGeneratedVideos(prevGeneratedVideos => {
return [
...prevGeneratedVideos.filter(v => v.jobToken !== jobToken),
newGeneratedVideo,
];
});
// Set the video source from the clicked job, regardless of its timestamp relative to others
if (
selectedSourceIndex === newGeneratedVideo.sourceIndex &&
selectedMotionIndex === newGeneratedVideo.motionIndex
) {
setGeneratedVideoSrc(mediaLink);
setIsGenerating(false);
}
setJobProcessedTokens(prevTokens => [...prevTokens, jobToken]);
} else {
console.error(
"Failed to retrieve media or media has no public bucket path",
response
);
setIsGenerating(false);
setGeneratedVideoSrc("");
setIsEnqueuing(false);
}
};
const getLatestVideoForCombination = useCallback(
(sourceIndex: number, motionIndex: number) => {
const matchingVideos = generatedVideos.filter(
video =>
video.sourceIndex === sourceIndex && video.motionIndex === motionIndex
);
const sortedVideos = matchingVideos.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const latestVideo =
sortedVideos.length > 0 ? sortedVideos[0].videoSrc : null;
return latestVideo;
},
[generatedVideos]
);
useEffect(() => {
const latestVideoSrc = getLatestVideoForCombination(
selectedSourceIndex,
selectedMotionIndex
);
if (latestVideoSrc) {
setGeneratedVideoSrc(latestVideoSrc);
setIsGenerating(false);
} else if (!isUserUploaded) {
setGeneratedVideoSrc("");
setIsGenerating(false);
} else {
setGeneratedVideoSrc("");
}
}, [
selectedSourceIndex,
selectedMotionIndex,
generatedVideos,
isUserUploaded,
getLatestVideoForCombination,
]);
useEffect(() => {
const queryParams = new URLSearchParams(location.search);
const sourceToken = queryParams.get("source");
const motionToken = queryParams.get("motion");
if (sourceToken) {
setSourceTokens(prevTokens => {
const tokenIndex = prevTokens.indexOf(sourceToken);
if (tokenIndex !== -1) {
setSelectedSourceIndex(tokenIndex);
setIsUserUploaded(tokenIndex >= numberOfInitialSourceTokens);
return prevTokens;
} else {
const updatedTokens = [...prevTokens, sourceToken];
setSelectedSourceIndex(updatedTokens.length - 1);
setIsUserUploaded(true);
return updatedTokens;
}
});
}
if (motionToken) {
setMotionTokens(prevTokens => {
const tokenIndex = prevTokens.indexOf(motionToken);
if (tokenIndex !== -1) {
setSelectedMotionIndex(tokenIndex);
setIsUserUploaded(tokenIndex >= numberOfInitialMotionTokens);
return prevTokens;
} else {
const updatedTokens = [...prevTokens, motionToken];
setSelectedMotionIndex(updatedTokens.length - 1);
setIsUserUploaded(true);
return updatedTokens;
}
});
}
}, [
location.search,
numberOfInitialSourceTokens,
numberOfInitialMotionTokens,
]);
const uploadFocusPointSource = useMemo(() => {
const queryParams = new URLSearchParams(location.search);
const sourceToken = queryParams.get("source");
const motionToken = queryParams.get("motion");
if (motionToken && !sourceToken) {
return true;
} else if (!motionToken && sourceToken) {
return false;
} else {
return false;
}
}, [location.search]);
useEffect(() => {
// When switching source or motion indexes, check if the current combination is generating
const isCurrentlyGenerating = currentlyGeneratingList.some(
gen =>
gen.sourceIndex === selectedSourceIndex &&
gen.motionIndex === selectedMotionIndex
);
if (isCurrentlyGenerating) {
setIsGenerating(true);
} else if (generatedVideoSrc) {
setIsGenerating(false);
} else if (!isUserUploaded) {
setIsGenerating(false);
} else {
setIsGenerating(false);
}
}, [
selectedSourceIndex,
selectedMotionIndex,
currentlyGeneratingList,
generatedVideoSrc,
isUserUploaded,
]);
useEffect(() => {
if (generatedVideos.length > 0) {
const relevantVideos = generatedVideos.filter(video =>
currentlyGeneratingList.some(
gen =>
gen.sourceIndex === video.sourceIndex &&
gen.motionIndex === video.motionIndex
)
);
relevantVideos.forEach(video => {
if (video.jobToken) {
const jobIndex = currentlyGeneratingList.findIndex(
gen =>
gen.sourceIndex === video.sourceIndex &&
gen.motionIndex === video.motionIndex
);
if (jobIndex !== -1) {
const updatedGeneratingList = [...currentlyGeneratingList];
updatedGeneratingList.splice(jobIndex, 1);
setCurrentlyGeneratingList(updatedGeneratingList);
const isCurrentJobGenerating = currentlyGeneratingList.some(
gen =>
gen.sourceIndex === selectedSourceIndex &&
gen.motionIndex === selectedMotionIndex
);
if (!isCurrentJobGenerating) {
setGeneratedVideoSrc(video.videoSrc);
setIsGenerating(false);
}
}
}
});
}
}, [
generatedVideos,
currentlyGeneratingList,
selectedSourceIndex,
selectedMotionIndex,
]);
const handleDownloadClick = () => {
if (generatedVideoSrc) {
const link = document.createElement("a");
link.href = generatedVideoSrc;
link.download = "output_video.mp4";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
console.error("No video source available for download");
}
};
const signupCTA = (
<>
{!sessionFetched ? null : (
<div className="lp-signup-cta text-center">
<FontAwesomeIcon icon={faLock} className="fs-3 mb-3" />
<h4 className="mb-1 fw-bold">
You need to be logged in to use Live Portrait
</h4>
<p className="mb-4 opacity-75">
Please login or sign up to continue.
</p>
<div className="d-flex gap-2">
<Button
label="Login"
variant="action"
onClick={() => {
history.push("/login?redirect=/ai-live-portrait");
}}
/>
<Button
label="Sign up now"
onClick={() => {
history.push("/signup?redirect=/ai-live-portrait");
}}
/>
</div>
</div>
)}
</>
);
return (
<>
<Container type="panel" className="mt-3 mt-lg-5">
<Panel padding={true}>
<h1 className="fw-bold fs-1 mb-0">
<FontAwesomeIcon icon={faImageUser} className="me-3 fs-2" />
Webcam Acting
</h1>
<p
className="opacity-75 fw-medium"
style={{ marginBottom: "2.5rem" }}
>
Use AI to transfer facial expressions, audio, and vocals from one
face video to an image or video.
</p>
{!loggedIn && (
<div style={{ marginBottom: "2.5rem" }}>{signupCTA}</div>
)}
<div>
<div {...{ className: "fy-cam-lp-column-set" }}>
<div {...{ className: "fy-cam-lp-column" }}>
<ThumbnailMediaPicker
mediaTokens={sourceTokens}
selectedIndex={selectedSourceIndex}
handleThumbnailClick={handleSourceSelect}
title="Select Source"
description="This image or video is what the final video will look like."
badgeLabel="Source Media"
stepNumber={1}
onUploadClick={handleOpenUploadSourceModal}
onSelectedMediaChange={handleSelectedMediaChange}
uploadFocusPoint={uploadFocusPointSource}
uploadButtonText={
loggedIn
? "Upload your image/video"
: "Sign up to upload image/video"
}
/>
</div>
<div {...{ className: "fy-cam-lp-column" }}>
<div {...{ className: "fy-cam-lp-camera-section" }}>
<CameraInput
{...{
onChange: ({ target }: CameraInputEvent) =>
cameraInputTokenSet(target.value),
}}
/>
<InputDescription
{...{
className: "fy-cam-lp-camera-description mt-3 mb-3",
description:
"Use your webcam to create a motion reference",
stepNumber: 2,
title: "Record yourself",
}}
/>
</div>
<div className="d-flex flex-column gap-4">
<div className="d-flex gap-2">
<Button
{...{
className: "flex-grow-1",
disabled: !loggedIn || !cameraInputToken,
icon: faSparkles,
label: loggedIn
? generatedVideoSrc
? "Re-animate"
: "Animate"
: "Sign up now to Animate",
onClick: loggedIn
? enqueueClick
: () =>
history.push(
"/signup?redirect=/ai-live-portrait"
),
isLoading: isEnqueuing || isGenerating,
}}
/>
<Tippy theme="fakeyou" content="Download video">
<div>
<Button
square={true}
icon={faArrowDownToLine}
variant="action"
onClick={handleDownloadClick}
disabled={!loggedIn}
/>
</div>
</Tippy>
</div>
<div className="d-flex flex-column gap-2">
<div className="d-flex gap-3">
<Checkbox
disabled={!hasPremium}
label={"Make Private"}
onChange={() => {
setVisibility(prevVisibility =>
prevVisibility === "private" ? "public" : "private"
);
}}
checked={visibility === "private"}
/>
<Checkbox
disabled={!hasPremium}
label={"Remove Watermark"}
onChange={() => {
setRemoveWatermark(
prevRemoveWatermark => !prevRemoveWatermark
);
}}
checked={removeWatermark}
/>
</div>
{!hasPremium && (
<div className="d-flex">
<Button
variant="link"
label="Upgrade to Premium to use features above"
icon={faStars}
to="/pricing"
/>
</div>
)}
</div>
</div>
</div>
</div>
{loggedIn && (
<div className="mt-5 pt-3 order-3">
<Label label="Latest Outputs" />
<div>
<SessionLpInferenceResultsList
sessionSubscriptionsWrapper={sessionSubscriptions}
onJobTokens={handleJobTokens}
addSourceToken={(newToken: string) =>
setSourceTokens(prevTokens =>
prevTokens.includes(newToken)
? prevTokens
: [...prevTokens, newToken]
)
}
addMotionToken={(newToken: string) =>
setMotionTokens(prevTokens =>
prevTokens.includes(newToken)
? prevTokens
: [...prevTokens, newToken]
)
}
onJobClick={handleJobClick}
onJobProgress={handleJobProgress}
onJobStateChange={handleJobStateChange}
/>
</div>
</div>
)}
</div>
</Panel>
</Container>
<Container type="panel" className="pt-5 mt-5">
<Panel clear={true}>
<h2 className="fw-bold mb-3">Try other AI video tools</h2>
<AITools />
</Panel>
</Container>
</>
);
}