From fd5d2a06082217242c48840724099c47e9223f9b Mon Sep 17 00:00:00 2001 From: Victor Date: Tue, 3 Sep 2024 16:50:33 -0700 Subject: [PATCH] added webcam acting page --- .../fakeyou.com/public/static-404.css | 29 +- .../common/CameraInput/CameraInput.scss | 50 ++ .../common/CameraInput/CameraInput.tsx | 130 +++ .../components/common/CameraInput/index.tsx | 2 + .../InputDescription/InputDescription.tsx | 30 + .../common/InputDescription/index.tsx | 1 + .../common/RecordToggle/RecordToggle.scss | 68 ++ .../common/RecordToggle/RecordToggle.tsx | 87 ++ .../components/common/RecordToggle/index.tsx | 2 + .../src/components/common/index.tsx | 12 + .../entities/EntityInput/EntityInputEmpty.tsx | 23 +- .../components/marketing/AITools/AITools.tsx | 2 +- .../modals/CameraCapture/CameraCapture.scss | 4 + .../modals/CameraCapture/CameraCapture.tsx | 155 +--- .../packages/fakeyou.com/src/hooks/index.tsx | 8 +- .../fakeyou.com/src/hooks/useCameraState.tsx | 116 +++ .../src/hooks/useMedia/useMedia.tsx | 8 + .../fakeyou.com/src/scss/site-refresh.scss | 16 + .../fakeyou.com/src/v2/view/PageContainer.tsx | 5 + .../live_portrait/CameraLivePortrait.scss | 26 + .../live_portrait/CameraLivePortrait.tsx | 792 ++++++++++++++++++ 21 files changed, 1414 insertions(+), 152 deletions(-) create mode 100644 src/website/packages/fakeyou.com/src/components/common/CameraInput/CameraInput.scss create mode 100644 src/website/packages/fakeyou.com/src/components/common/CameraInput/CameraInput.tsx create mode 100644 src/website/packages/fakeyou.com/src/components/common/CameraInput/index.tsx create mode 100644 src/website/packages/fakeyou.com/src/components/common/InputDescription/InputDescription.tsx create mode 100644 src/website/packages/fakeyou.com/src/components/common/InputDescription/index.tsx create mode 100644 src/website/packages/fakeyou.com/src/components/common/RecordToggle/RecordToggle.scss create mode 100644 src/website/packages/fakeyou.com/src/components/common/RecordToggle/RecordToggle.tsx create mode 100644 src/website/packages/fakeyou.com/src/components/common/RecordToggle/index.tsx create mode 100644 src/website/packages/fakeyou.com/src/hooks/useCameraState.tsx create mode 100644 src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/CameraLivePortrait.scss create mode 100644 src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/CameraLivePortrait.tsx diff --git a/src/website/packages/fakeyou.com/public/static-404.css b/src/website/packages/fakeyou.com/public/static-404.css index c5f46377eb..c63958b0f9 100644 --- a/src/website/packages/fakeyou.com/public/static-404.css +++ b/src/website/packages/fakeyou.com/public/static-404.css @@ -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; -} \ No newline at end of file +} diff --git a/src/website/packages/fakeyou.com/src/components/common/CameraInput/CameraInput.scss b/src/website/packages/fakeyou.com/src/components/common/CameraInput/CameraInput.scss new file mode 100644 index 0000000000..0757814636 --- /dev/null +++ b/src/website/packages/fakeyou.com/src/components/common/CameraInput/CameraInput.scss @@ -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; + } +} diff --git a/src/website/packages/fakeyou.com/src/components/common/CameraInput/CameraInput.tsx b/src/website/packages/fakeyou.com/src/components/common/CameraInput/CameraInput.tsx new file mode 100644 index 0000000000..3e3f0e8f6c --- /dev/null +++ b/src/website/packages/fakeyou.com/src/components/common/CameraInput/CameraInput.tsx @@ -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 ( +
+ {transitions((style: any, hasToken: boolean) => + hasToken ? ( + +
+ ); +} diff --git a/src/website/packages/fakeyou.com/src/components/common/CameraInput/index.tsx b/src/website/packages/fakeyou.com/src/components/common/CameraInput/index.tsx new file mode 100644 index 0000000000..4784bd520b --- /dev/null +++ b/src/website/packages/fakeyou.com/src/components/common/CameraInput/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./CameraInput"; +export type { CameraInputEvent } from "./CameraInput"; diff --git a/src/website/packages/fakeyou.com/src/components/common/InputDescription/InputDescription.tsx b/src/website/packages/fakeyou.com/src/components/common/InputDescription/InputDescription.tsx new file mode 100644 index 0000000000..735c2d8062 --- /dev/null +++ b/src/website/packages/fakeyou.com/src/components/common/InputDescription/InputDescription.tsx @@ -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 ( +
+
+ {stepNumber &&
{stepNumber}
} +

{title}

+
+ +

{description}

+
+ ); +} diff --git a/src/website/packages/fakeyou.com/src/components/common/InputDescription/index.tsx b/src/website/packages/fakeyou.com/src/components/common/InputDescription/index.tsx new file mode 100644 index 0000000000..6fd0949a5d --- /dev/null +++ b/src/website/packages/fakeyou.com/src/components/common/InputDescription/index.tsx @@ -0,0 +1 @@ +export { default } from "./InputDescription"; diff --git a/src/website/packages/fakeyou.com/src/components/common/RecordToggle/RecordToggle.scss b/src/website/packages/fakeyou.com/src/components/common/RecordToggle/RecordToggle.scss new file mode 100644 index 0000000000..e7c5f17955 --- /dev/null +++ b/src/website/packages/fakeyou.com/src/components/common/RecordToggle/RecordToggle.scss @@ -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; + } + } +} diff --git a/src/website/packages/fakeyou.com/src/components/common/RecordToggle/RecordToggle.tsx b/src/website/packages/fakeyou.com/src/components/common/RecordToggle/RecordToggle.tsx new file mode 100644 index 0000000000..dfabb7de63 --- /dev/null +++ b/src/website/packages/fakeyou.com/src/components/common/RecordToggle/RecordToggle.tsx @@ -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 ( + + ); +} diff --git a/src/website/packages/fakeyou.com/src/components/common/RecordToggle/index.tsx b/src/website/packages/fakeyou.com/src/components/common/RecordToggle/index.tsx new file mode 100644 index 0000000000..6cdafd3e91 --- /dev/null +++ b/src/website/packages/fakeyou.com/src/components/common/RecordToggle/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./RecordToggle"; +export type { RecordToggleEvent, RecordToggleProps } from "./RecordToggle"; diff --git a/src/website/packages/fakeyou.com/src/components/common/index.tsx b/src/website/packages/fakeyou.com/src/components/common/index.tsx index c8eaf2cd05..e0015c1de0 100644 --- a/src/website/packages/fakeyou.com/src/components/common/index.tsx +++ b/src/website/packages/fakeyou.com/src/components/common/index.tsx @@ -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, diff --git a/src/website/packages/fakeyou.com/src/components/entities/EntityInput/EntityInputEmpty.tsx b/src/website/packages/fakeyou.com/src/components/entities/EntityInput/EntityInputEmpty.tsx index 9186d90353..5e69a2a053 100644 --- a/src/website/packages/fakeyou.com/src/components/entities/EntityInput/EntityInputEmpty.tsx +++ b/src/website/packages/fakeyou.com/src/components/entities/EntityInput/EntityInputEmpty.tsx @@ -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 ( <> diff --git a/src/website/packages/fakeyou.com/src/components/marketing/AITools/AITools.tsx b/src/website/packages/fakeyou.com/src/components/marketing/AITools/AITools.tsx index fad2363c01..e01af3dee7 100644 --- a/src/website/packages/fakeyou.com/src/components/marketing/AITools/AITools.tsx +++ b/src/website/packages/fakeyou.com/src/components/marketing/AITools/AITools.tsx @@ -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", diff --git a/src/website/packages/fakeyou.com/src/components/modals/CameraCapture/CameraCapture.scss b/src/website/packages/fakeyou.com/src/components/modals/CameraCapture/CameraCapture.scss index d91152fce9..bcaf1bb072 100644 --- a/src/website/packages/fakeyou.com/src/components/modals/CameraCapture/CameraCapture.scss +++ b/src/website/packages/fakeyou.com/src/components/modals/CameraCapture/CameraCapture.scss @@ -28,6 +28,10 @@ position: absolute; width: 100%; height: 100%; + + video { + height: 100%; + } } .fy-camera-capture-centered { diff --git a/src/website/packages/fakeyou.com/src/components/modals/CameraCapture/CameraCapture.tsx b/src/website/packages/fakeyou.com/src/components/modals/CameraCapture/CameraCapture.tsx index 13f4afd2d0..098c2abac2 100644 --- a/src/website/packages/fakeyou.com/src/components/modals/CameraCapture/CameraCapture.tsx +++ b/src/website/packages/fakeyou.com/src/components/modals/CameraCapture/CameraCapture.tsx @@ -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(null); - const mediaRecorderRef = useRef(null); - const [capturing, capturingSet] = useState(false); - const [recordedChunks, recordedChunksSet] = useState([]); - const [counter, counterSet] = useState(0); - const [blob, blobSet] = useState(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 (
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 ? (
- + /> {isMobile && ( ; + reset: () => void; + started: boolean; + startedSet: (startedState: boolean) => void; + toggle: (e: RecordToggleEvent) => void; +} + +export default function useCameraState(autoCapture = false): CameraState { + const ref = useRef(null); + const mediaRecorderRef = useRef(null); + + const [blob, blobSet] = useState(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 }; +} diff --git a/src/website/packages/fakeyou.com/src/hooks/useMedia/useMedia.tsx b/src/website/packages/fakeyou.com/src/hooks/useMedia/useMedia.tsx index d31bba251d..e327601dd8 100644 --- a/src/website/packages/fakeyou.com/src/hooks/useMedia/useMedia.tsx +++ b/src/website/packages/fakeyou.com/src/hooks/useMedia/useMedia.tsx @@ -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, diff --git a/src/website/packages/fakeyou.com/src/scss/site-refresh.scss b/src/website/packages/fakeyou.com/src/scss/site-refresh.scss index 255000f354..26e9091db7 100644 --- a/src/website/packages/fakeyou.com/src/scss/site-refresh.scss +++ b/src/website/packages/fakeyou.com/src/scss/site-refresh.scss @@ -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 { diff --git a/src/website/packages/fakeyou.com/src/v2/view/PageContainer.tsx b/src/website/packages/fakeyou.com/src/v2/view/PageContainer.tsx index 125c1234f5..83a9913daa 100644 --- a/src/website/packages/fakeyou.com/src/v2/view/PageContainer.tsx +++ b/src/website/packages/fakeyou.com/src/v2/view/PageContainer.tsx @@ -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 for initial voice creation */} ("public"); + const hasPremium = sessionSubscriptions?.hasPaidFeatures(); + const [generatedVideoSrc, setGeneratedVideoSrc] = useState(""); + const [sourceTokens, setSourceTokens] = useState([ + ...PRECOMPUTED_SOURCE_TOKENS, + ]); + const [motionTokens, setMotionTokens] = useState([ + ...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(""); + const [userMotionToken, setUserMotionToken] = useState(""); + const [isUserUploaded, setIsUserUploaded] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [generatedVideos, setGeneratedVideos] = useState([]); + const [jobProcessedTokens, setJobProcessedTokens] = useState([]); + const [currentlyGeneratingList, setCurrentlyGeneratingList] = useState< + CurrentlyGenerating[] + >([]); + const [jobProgress, setJobProgress] = useState({}); + 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 : ( +
+ +

+ You need to be logged in to use Live Portrait +

+

+ Please login or sign up to continue. +

+
+
+
+ )} + + ); + + return ( + <> + + +

+ + Webcam Acting +

+

+ Use AI to transfer facial expressions, audio, and vocals from one + face video to an image or video. +

+ + {!loggedIn && ( +
{signupCTA}
+ )} + +
+
+
+ +
+
+
+ + cameraInputTokenSet(target.value), + }} + /> + +
+
+
+
+ +
+ +
+
+ { + setVisibility(prevVisibility => + prevVisibility === "private" ? "public" : "private" + ); + }} + checked={visibility === "private"} + /> + + { + setRemoveWatermark( + prevRemoveWatermark => !prevRemoveWatermark + ); + }} + checked={removeWatermark} + /> +
+ + {!hasPremium && ( +
+
+ )} +
+
+
+
+ + {loggedIn && ( +
+
+ )} +
+ + + + + +

Try other AI video tools

+ +
+
+ + ); +}