mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 01:38:44 +00:00
Merge pull request #692 from storytold/feature/webcam-acting-page
added webcam acting page
This commit is contained in:
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";
|
||||
+30
@@ -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,
|
||||
|
||||
+14
-9
@@ -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",
|
||||
|
||||
+4
@@ -28,6 +28,10 @@
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
video {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.fy-camera-capture-centered {
|
||||
|
||||
+27
-128
@@ -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
|
||||
|
||||
+26
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+792
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user