diff --git a/src/netlify_configs/fakeyou.com/_redirects b/src/netlify_configs/fakeyou.com/_redirects index 0db8b9eaed..a42032d5de 100644 --- a/src/netlify_configs/fakeyou.com/_redirects +++ b/src/netlify_configs/fakeyou.com/_redirects @@ -97,6 +97,7 @@ # /video* /website/index.html 200 # Test paths +/dev-lp* /website/index.html 200 /dev-tts* /website/index.html 200 /dev-vc* /website/index.html 200 /dev-upload* /website/index.html 200 diff --git a/src/website/packages/fakeyou.com/public/videos/live-portrait/1_1.mp4 b/src/website/packages/fakeyou.com/public/videos/live-portrait/1_1.mp4 new file mode 100644 index 0000000000..b45a5fe6b9 Binary files /dev/null and b/src/website/packages/fakeyou.com/public/videos/live-portrait/1_1.mp4 differ diff --git a/src/website/packages/fakeyou.com/public/videos/live-portrait/1_2.mp4 b/src/website/packages/fakeyou.com/public/videos/live-portrait/1_2.mp4 new file mode 100644 index 0000000000..59aa6aef5a Binary files /dev/null and b/src/website/packages/fakeyou.com/public/videos/live-portrait/1_2.mp4 differ diff --git a/src/website/packages/fakeyou.com/src/components/common/Checkbox/Checkbox.scss b/src/website/packages/fakeyou.com/src/components/common/Checkbox/Checkbox.scss index 3e8ca33dd1..3f08f5f0b8 100644 --- a/src/website/packages/fakeyou.com/src/components/common/Checkbox/Checkbox.scss +++ b/src/website/packages/fakeyou.com/src/components/common/Checkbox/Checkbox.scss @@ -1,6 +1,8 @@ @import "scss/colours.scss"; .fy-checkbox-frame { + display: flex; + align-items: center; margin-bottom: 0.5rem; white-space: nowrap; cursor: pointer; diff --git a/src/website/packages/fakeyou.com/src/components/providers/ModalProvider/ModalLayer.scss b/src/website/packages/fakeyou.com/src/components/providers/ModalProvider/ModalLayer.scss index fa2cd4e58b..7109ef0be7 100644 --- a/src/website/packages/fakeyou.com/src/components/providers/ModalProvider/ModalLayer.scss +++ b/src/website/packages/fakeyou.com/src/components/providers/ModalProvider/ModalLayer.scss @@ -28,8 +28,13 @@ max-width: 50vw; } + .fy-modal-body-small { + max-width: 600px; + } + .fy-modal-body-wide, - .fy-modal-body-narrow { + .fy-modal-body-narrow, + .fy-modal-body-small { width: 100%; height: fit-content; overflow: hidden; diff --git a/src/website/packages/fakeyou.com/src/hooks/useModalState.tsx b/src/website/packages/fakeyou.com/src/hooks/useModalState.tsx index a3d839d9ea..27f1036b60 100644 --- a/src/website/packages/fakeyou.com/src/hooks/useModalState.tsx +++ b/src/website/packages/fakeyou.com/src/hooks/useModalState.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { isMobile } from "react-device-detect"; -export type ModalWidth = "wide" | "narrow"; +export type ModalWidth = "wide" | "narrow" | "small"; export interface ModalConfig { component: React.ElementType; 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 d40a00c49f..368e5f0a6f 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 { CreatorTypeformPage } from "./pages/landing/storyteller/PostlaunchLandi 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"; interface Props { sessionWrapper: SessionWrapper; @@ -753,6 +754,15 @@ class PageContainer extends React.Component< /> + {/* NEW LIVE PORTRAIT PAGE */} + + + + {/* Route for initial voice creation */} void; + onZoomChange: (zoom: number) => void; + zoomWithScroll?: boolean; + showGrid?: boolean; + isCropping?: boolean; + mediaProps?: any; + onCropComplete?: (croppedArea: any, croppedAreaPixels: any) => void; +} + +const FaceCropper: React.FC = ({ + videoSrc, + crop, + zoom, + onCropChange, + onZoomChange, + zoomWithScroll = false, + showGrid = false, + isCropping, + onCropComplete, + ...props +}) => { + return ( + + ); +}; + +export default FaceCropper; diff --git a/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/LivePortrait.scss b/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/LivePortrait.scss new file mode 100644 index 0000000000..8d817a7b5d --- /dev/null +++ b/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/LivePortrait.scss @@ -0,0 +1,68 @@ +@import "scss/colours.scss"; + +.lp-media { + position: relative; + overflow: hidden; + border-radius: 0.5rem; + border: lighten($panel, 15%) solid 2px; + background-color: darken($panel, 4%); + aspect-ratio: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + + img, + video { + width: 100%; + height: 100%; + object-fit: contain; + } +} + +.lp-section-between { + padding-top: 15%; + + @media (max-width: 992px) { + padding-top: 0; + } +} + +.lp-thumbnail { + cursor: pointer; + opacity: 0.6; + transition: all 0.2s; + border: lighten($panel, 15%) solid 2px; + border-radius: 0.5rem; + overflow: hidden; + + &:hover { + border-color: lighten($panel, 30%); + opacity: 0.75; + } + + &.active { + opacity: 1; + border: 2px solid $primary; + } + + &.lp-add-media { + background-color: lighten($panel, 6%); + } +} + +.lp-tag { + display: flex; + width: 100%; + justify-content: space-between; + position: absolute; + top: 0; + left: 0; + padding-top: 10px; + padding-left: 8px; + padding-right: 8px; + z-index: 2; +} + +.cursor-default { + cursor: default; +} diff --git a/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/LivePortrait.tsx b/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/LivePortrait.tsx new file mode 100644 index 0000000000..1e5a381165 --- /dev/null +++ b/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/LivePortrait.tsx @@ -0,0 +1,273 @@ +import React, { useEffect, useState } from "react"; +import { + Badge, + Button, + Checkbox, + Container, + Label, + Panel, +} from "components/common"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faArrowDown, + faArrowDownToLine, + faEquals, + faImageUser, + faPlus, + faSparkles, +} from "@fortawesome/pro-solid-svg-icons"; +import "./LivePortrait.scss"; +import ThumbnailMediaPicker from "./ThumbnailMediaPicker"; +import { + EnqueueFaceMirror, + EnqueueFaceMirrorResponse, + MediaFileCropArea, +} 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 } from "hooks"; +import { FrontendInferenceJobType } from "@storyteller/components/src/jobs/InferenceJob"; +import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper"; + +interface LivePortraitProps { + sessionSubscriptionsWrapper: SessionSubscriptionsWrapper; +} + +export default function LivePortrait({ + sessionSubscriptionsWrapper, +}: LivePortraitProps) { + const { enqueue } = useInferenceJobs(); + 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 [cropArea, setCropArea] = useState({ + height: 0, + width: 0, + x: 0, + y: 0, + }); + const hasPremium = sessionSubscriptionsWrapper.hasPaidFeatures(); + const [videoSrc, setVideoSrc] = useState(""); + + const sourceTokens = ["m_f68s71xwm1r8y2rv4q0xb4dmc4nx9f"]; + + const motionTokens = [ + "m_x2t1kvyzw677b6kr5me2g4vcra4308", + "m_hv1n7nqbq5dmfmzcn1eyy1qw4mskvs", + ]; + + const precomputedVideos = [ + { + src: "/videos/live-portrait/1_1.mp4", + }, + { + src: "/videos/live-portrait/1_2.mp4", + }, + ]; + + const handleSourceSelect = (index: number) => { + console.log("Thumbnail clicked:", index); + setSelectedSourceIndex(index); + }; + + const handleMotionSelect = (index: number) => { + console.log("Thumbnail clicked:", index); + setSelectedMotionIndex(index); + }; + + const enqueueClick = () => { + setIsEnqueuing(true); + EnqueueFaceMirror("", { + creator_set_visibility: visibility, + face_driver_media_file_token: motionTokens[selectedMotionIndex], + maybe_crop: cropArea, + remove_watermark: removeWatermark, + source_media_file_token: sourceTokens[selectedSourceIndex], + uuid_idempotency_token: uuidv4(), + }).then((res: EnqueueFaceMirrorResponse) => { + if (res.success && res.inference_job_token) { + enqueue( + res.inference_job_token, + FrontendInferenceJobType.VideoStyleTransfer, + false + ); + } else { + console.error("Failed to enqueue job", res); + } + setIsEnqueuing(false); + }); + }; + + const getPrecomputedVideoSrc = () => { + const index = selectedSourceIndex * 4 + selectedMotionIndex; + return precomputedVideos[index].src; + }; + + useEffect(() => { + setVideoSrc(getPrecomputedVideoSrc()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedSourceIndex, selectedMotionIndex]); + + const isUserContent = + selectedSourceIndex >= sourceTokens.length || + selectedMotionIndex >= motionTokens.length; + + return ( + + +

+ + Live Portrait +

+

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

+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ + {!isUserContent && ( + + )} +
+
+
+ +
+
+ { + setVisibility(prevVisibility => + prevVisibility === "public" ? "private" : "public" + ); + }} + checked={visibility === "public"} + /> + +
+ { + setRemoveWatermark( + prevRemoveWatermark => !prevRemoveWatermark + ); + }} + checked={removeWatermark} + /> + {/*
+ Watermark + {!hasPremium ? ( + subscribe to remove + ) : null} +
*/} +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+ ); +} diff --git a/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/ThumbnailItem.tsx b/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/ThumbnailItem.tsx new file mode 100644 index 0000000000..5db766e831 --- /dev/null +++ b/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/ThumbnailItem.tsx @@ -0,0 +1,62 @@ +import LoadingSpinner from "components/common/LoadingSpinner"; +import React from "react"; + +interface ThumbnailItemProps { + index: number; + selectedIndex: number; + handleThumbnailClick: (index: number) => void; + poster?: string; + mediaType?: string; +} + +const ThumbnailItem: React.FC = ({ + index, + selectedIndex, + handleThumbnailClick, + poster, + mediaType, +}) => { + return ( +
+
handleThumbnailClick(index)} + > + {/* {thumbnail ? ( + Video Thumbnail + ) : ( + + )} */} + {poster ? ( + mediaType === "image" ? ( + Media Thumbnail + ) : ( +
+
+ ); +}; + +export default ThumbnailItem; diff --git a/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/ThumbnailMediaPicker.tsx b/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/ThumbnailMediaPicker.tsx new file mode 100644 index 0000000000..d90da1d78d --- /dev/null +++ b/src/website/packages/fakeyou.com/src/v2/view/pages/live_portrait/ThumbnailMediaPicker.tsx @@ -0,0 +1,238 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Badge, Button } from "components/common"; +import FaceCropper from "./FaceCropper"; +import ThumbnailItem from "./ThumbnailItem"; +import { BucketConfig } from "@storyteller/components/src/api/BucketConfig"; +import LoadingSpinner from "components/common/LoadingSpinner"; +import { + GetMedia, + MediaFile, +} from "@storyteller/components/src/api/media_files/GetMedia"; +import { faUpload } from "@fortawesome/pro-solid-svg-icons"; +import { isMobile } from "react-device-detect"; + +interface ThumbnailMediaPickerProps { + mediaTokens: string[]; + selectedIndex: number; + handleThumbnailClick: (index: number) => void; + title?: string; + description?: string; + badgeLabel?: string; + cropper?: true; + showCropButton?: boolean; + cropArea?: { x: number; y: number; height: number; width: number }; + setCropArea?: (cropArea: { + x: number; + y: number; + height: number; + width: number; + }) => void; +} + +interface MediaData { + [key: string]: MediaFile | undefined; +} + +const ThumbnailMediaPicker: React.FC = React.memo( + ({ + selectedIndex, + handleThumbnailClick, + badgeLabel = "Media", + title = "Select Source", + description = "This image or video is what the final video will look like.", + cropper = false, + cropArea, + setCropArea, + mediaTokens, + showCropButton = true, + }) => { + const [zoom, setZoom] = useState(1); + const [isCropping, setIsCropping] = useState(false); + const [mediaData, setMediaData] = useState<{ [key: string]: any }>({}); + + useEffect(() => { + const fetchMediaData = async () => { + const mediaDataPromises = mediaTokens.map(async token => { + const response = await GetMedia(token, {}); + return { token, media: response.media_file }; + }); + + const mediaDataArray = await Promise.all(mediaDataPromises); + const mediaDataObject = mediaDataArray.reduce( + (acc, { token, media }) => { + acc[token] = media; + return acc; + }, + {} as MediaData + ); + + setMediaData(mediaDataObject); + }; + + fetchMediaData(); + }, [mediaTokens]); + + const selectedMedia = mediaData[mediaTokens[selectedIndex]]; + const mediaLink = selectedMedia?.public_bucket_path + ? new BucketConfig().getGcsUrl(selectedMedia.public_bucket_path) + : null; + + useEffect(() => { + if (setCropArea) { + setCropArea({ x: 0, y: 0, height: 0, width: 0 }); + setZoom(1); + } + }, [selectedIndex, setCropArea]); + + const onCropChange = useCallback( + crop => { + if (setCropArea) { + if (isCropping) { + setCropArea(crop); + } + } + }, + [isCropping, setCropArea] + ); + + const onZoomChange = useCallback( + zoom => { + if (isCropping) { + setZoom(zoom); + } + }, + [isCropping] + ); + + const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => { + console.log(croppedArea, croppedAreaPixels, "HELLO"); + }, []); + + return ( +
+
+
+
+ {!isCropping ? ( + + ) : ( + + )} +
+ {cropper && showCropButton && ( +
+
+ )} +
+ + {cropper && cropArea ? ( + <> + {mediaLink ? ( + + ) : ( + + )} + + ) : ( +
+ {mediaLink ? ( + selectedMedia?.media_type === "image" ? ( + Selected media + ) : ( + + ) + ) : ( + + )} +
+ )} +
+ +
+

{title}

+

{description}

+
+ +
+ {mediaTokens.map((token, index) => { + const media = mediaData[token]; + const mediaLink = media?.public_bucket_path + ? new BucketConfig().getGcsUrl(media.public_bucket_path) + : null; + + return ( + + ); + })} + + {/*
+
+
+ + + Upload + +
+
+
*/} +
+ +
+ ); + } +); + +export default ThumbnailMediaPicker;