new lipsync first push dev (#694)

* init new lipsync

* update
This commit is contained in:
BFlat authored and GitHub committed 2024-09-05 18:34:28 +07:00
1 parent 6d05f7c073
commit b1db48ff3c
13 files changed
+1737 -58

No files matched your search

@@ -46,6 +46,7 @@ export interface MediaFile {
maybe_moderator_fields?: MediaFileModeratorFields;
created_at: Date;
updated_at: Date;
maybe_text_transcript: string | undefined;
}
export interface MediaFileModeratorFields {
@@ -0,0 +1,31 @@
import MakeRequest from "../MakeRequest";
export interface MediaFileCropArea {
height: number;
width: number;
x: number;
y: number;
}
export interface EnqueueLipsyncRequest {
creator_set_visibility: "public" | "private";
audio_media_file_token: string;
image_or_video_media_file_token: string;
maybe_crop?: MediaFileCropArea;
remove_watermark: boolean;
uuid_idempotency_token: string;
}
export interface EnqueueLipsyncResponse {
inference_job_token?: string;
success: boolean;
}
export const EnqueueLipsync = MakeRequest<
string,
EnqueueLipsyncRequest,
EnqueueLipsyncResponse,
{}
>({
method: "POST",
routingFunction: () => `/v1/workflows/enqueue_lipsync`,
});
@@ -19,6 +19,7 @@ export enum FrontendInferenceJobType {
VideoWorkflow, // workflow ?
VideoStyleTransfer, // video_filter
LivePortrait, // live_portrait
Lipsync, // lip_sync
}
export enum AllInferenceJobs {
@@ -7,6 +7,7 @@ interface TextAreaProps
textArea?: boolean;
required?: boolean;
resize?: boolean;
className?: string;
}
export default function TextArea({
@@ -14,17 +15,20 @@ export default function TextArea({
textArea,
required,
resize = true,
className,
...rest
}: TextAreaProps) {
return (
<div className="fy-textarea">
<div className="fy-textarea flex-grow-1 position-relative">
{label && (
<label className={`sub-title ${required ? "required" : ""}`.trim()}>
{label}
</label>
)}
<textarea
className={`form-control ${resize ? "" : "no-resize"}`.trim()}
className={`form-control ${
resize ? "" : "no-resize"
} ${className}`.trim()}
{...rest}
/>
</div>
@@ -142,6 +142,7 @@ import { BetaWebcamActingForm } from "./pages/beta_products/BetaWebcamActingForm
import { BetaWebcamActingPage } from "./pages/beta_products/BetaWebcamActingPage";
import { Beta3DVideoCompositorForm } from "./pages/beta_products/Beta3DVideoCompositorForm";
import { Beta3DVideoCompositorPage } from "./pages/beta_products/Beta3DVideoCompositorPage";
import Lipsync from "./pages/lipsync/Lipsync";
interface Props {
sessionWrapper: SessionWrapper;
@@ -763,6 +764,14 @@ class PageContainer extends React.Component<
/>
</Route>
<Route exact path="/dev/lipsync">
<Lipsync
sessionSubscriptionsWrapper={
this.props.sessionSubscriptionsWrapper
}
/>
</Route>
<Route exact path="/webcam-acting">
<CameraLivePortrait />
</Route>
@@ -0,0 +1,306 @@
import React, { useEffect, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import {
faPause,
faPlay,
faSquareQuote,
faWaveformLines,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
import {
GenerateTtsAudio,
GenerateTtsAudioErrorType,
GenerateTtsAudioIsError,
GenerateTtsAudioIsOk,
} from "@storyteller/components/src/api/tts/GenerateTtsAudio";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { Button, Panel, TextArea } from "components/common";
import { useInferenceJobs } from "hooks";
import LipsyncAudioPlayer from "./LipsyncAudioPlayer";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { GetMedia } from "@storyteller/components/src/api/media_files/GetMedia";
interface GenerateTtsProps {
weightToken?: string | null;
onResultToken?: (token: string | null) => void;
}
export const GenerateTts = ({
weightToken,
onResultToken,
}: GenerateTtsProps) => {
const [textBuffer, setTextBuffer] = useState("");
const [maybeTtsError, setMaybeTtsError] = useState<
GenerateTtsAudioErrorType | undefined
>(undefined);
const [currentAudioUrl, setCurrentAudioUrl] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [jobToken, setJobToken] = useState<string | null>(null);
const [isAudioLoading, setIsAudioLoading] = useState(false);
const [voiceToken] = useState(
weightToken || "weight_hz7g8f1j4psrsw2sv67e4y61q"
);
const [progress, setProgress] = useState(0);
const { enqueueInferenceJob, inferenceJobs } = useInferenceJobs();
const [transcript, setTranscript] = useState<string | null>(null);
const handleChangeText = (ev: React.FormEvent<HTMLTextAreaElement>) => {
const textValue = (ev.target as HTMLTextAreaElement).value;
setTextBuffer(textValue);
};
const handleEnqueueTts = async (ev: React.FormEvent<HTMLButtonElement>) => {
ev.preventDefault();
if (!textBuffer) {
return false;
}
// Check if the text hasn't changed and the voice hasn't changed
setIsAudioLoading(true);
const modelToken = voiceToken;
const request = {
uuid_idempotency_token: uuidv4(),
tts_model_token: modelToken,
inference_text: textBuffer,
};
const response = await GenerateTtsAudio(request);
if (GenerateTtsAudioIsOk(response)) {
setMaybeTtsError(undefined);
enqueueInferenceJob(
response.inference_job_token,
FrontendInferenceJobType.TextToSpeech
);
setJobToken(response.inference_job_token);
setTranscript(textBuffer);
} else if (GenerateTtsAudioIsError(response)) {
setMaybeTtsError(response.error);
}
return false;
};
useEffect(() => {
if (!jobToken) return;
const fetch = async () => {
const job = inferenceJobs.find(
(job: InferenceJob) => job.jobToken === jobToken
);
if (job && job.progressPercentage) {
setProgress(job.progressPercentage);
}
if (job && job.maybeResultToken) {
const url = new URL(window.location.href);
url.searchParams.set("audio", job.maybeResultToken);
window.history.replaceState({}, "", url.toString());
if (onResultToken) {
onResultToken(job.maybeResultToken);
}
}
if (job && job.maybeResultPublicBucketMediaPath) {
const audioLink = new BucketConfig().getGcsUrl(
job.maybeResultPublicBucketMediaPath
);
setCurrentAudioUrl(audioLink);
setProgress(0);
if (audioLink !== currentAudioUrl) {
setIsAudioLoading(false);
setIsPlaying(true);
}
}
};
fetch();
}, [currentAudioUrl, jobToken, inferenceJobs, onResultToken]);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const maybeResultToken = urlParams.get("audio");
if (maybeResultToken) {
const fetchMedia = async () => {
try {
const response = await GetMedia(maybeResultToken, {});
if (
response &&
response.media_file &&
response.media_file.public_bucket_path &&
response.media_file.maybe_text_transcript
) {
const audioLink = new BucketConfig().getGcsUrl(
response.media_file.public_bucket_path
);
setCurrentAudioUrl(audioLink);
setTranscript(response.media_file.maybe_text_transcript || "");
} else {
console.error(
"Failed to retrieve media or media has no public bucket path",
response
);
}
} catch (error) {
console.error("Error fetching media:", error);
}
};
fetchMedia();
}
}, []);
const handleAudioFinish = () => {
setIsPlaying(false);
};
const handleClearAudio = () => {
setCurrentAudioUrl(null);
setJobToken(null);
const url = new URL(window.location.href);
url.searchParams.delete("audio");
window.history.replaceState({}, "", url.toString());
if (onResultToken) {
onResultToken(null);
}
};
let maybeError = <></>;
if (!!maybeTtsError) {
let hasMessage = false;
let message = <></>;
switch (maybeTtsError) {
case GenerateTtsAudioErrorType.TooManyRequests:
hasMessage = true;
message = (
<>Too many requests! Please wait a few minutes then try again.</>
);
break;
case GenerateTtsAudioErrorType.ServerError |
GenerateTtsAudioErrorType.BadRequest |
GenerateTtsAudioErrorType.NotFound:
break;
}
if (hasMessage) {
maybeError = (
<div
className="alert alert-primary alert-dismissible fade show mt-3"
role="alert"
>
<button
className="btn-close"
onClick={() => setMaybeTtsError(undefined)}
data-bs-dismiss="alert"
aria-label="Close"
></button>
{message}
</div>
);
}
}
return (
<>
<div>
<div className="d-flex gap-2 align-items-center mb-1">
<div className="lp-step">2</div>
<h2 className="fs-5 mb-0 fw-semibold">Generate Audio</h2>
</div>
<p className="fw-medium fs-7 opacity-75">
What do you want your character to say?
</p>
</div>
<div className="ratio ratio-1x1">
<div className="d-flex flex-column h-100">
{currentAudioUrl ? (
<Panel
padding={true}
className="panel-inner h-100 position-relative"
>
<div className="d-flex flex-column justify-content-center h-100">
<div className="d-flex gap-3 align-items-center justify-content-center">
<Button
icon={isPlaying ? faPause : faPlay}
onClick={() => setIsPlaying(!isPlaying)}
isLoading={isAudioLoading}
square={true}
small={true}
/>
<div className="w-100">
<LipsyncAudioPlayer
filename={currentAudioUrl || ""}
play={isPlaying}
onFinish={handleAudioFinish}
/>
</div>
</div>
<div className="pt-4">
<h6 className="fw-bold">
<FontAwesomeIcon icon={faSquareQuote} className="me-2" />
Audio Transcript
</h6>
<p className="fs-7">{transcript}</p>
</div>
</div>
<div style={{ position: "absolute", top: "10px", right: "10px" }}>
<button
onClick={handleClearAudio}
className="ls-remove-audio-btn"
>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
</Panel>
) : (
<>
<TextArea
placeholder={"Type what you want your character to say..."}
value={textBuffer}
onChange={handleChangeText}
rows={4}
resize={false}
className="h-100"
autoFocus={true}
disabled={isAudioLoading}
/>
{maybeError}
<Button
label={
isAudioLoading
? `Generating Audio... ${
progress !== 0 ? progress + "%" : ""
}`
: "Generate audio"
}
variant="action"
icon={faWaveformLines}
onClick={handleEnqueueTts}
disabled={textBuffer.length === 0}
isLoading={isAudioLoading}
/>
</>
)}
</div>
</div>
</>
);
};
@@ -0,0 +1,28 @@
@import "scss/colours.scss";
.ls-remove-audio-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background-color: $action;
border: 2.5px solid;
border-color: $action;
transition: 0.2s;
color: $white !important;
&:hover {
background-color: lighten($action, 5%);
border-color: lighten($action, 5%);
color: $white !important;
}
border-radius: 0.5rem;
}
.ls-section-between {
padding-top: 16%;
@media (max-width: 992px) {
padding-top: 0;
}
}
@@ -0,0 +1,998 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Badge,
Button,
Checkbox,
Container,
Label,
Panel,
} from "components/common";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDown,
faArrowDownToLine,
faEquals,
faLips,
faLock,
faMicrophone,
faPlus,
faSparkles,
faWaveformLines,
} from "@fortawesome/pro-solid-svg-icons";
import "../live_portrait/LivePortrait.scss";
import "./Lipsync.scss";
import {
EnqueueLipsync,
EnqueueLipsyncResponse,
MediaFileCropArea,
} from "@storyteller/components/src/api/workflows/EnqueueLipsync";
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
import { v4 as uuidv4 } from "uuid";
import { useInferenceJobs, useSession } from "hooks";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import { AITools } from "components/marketing";
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
import LoadingSpinner from "components/common/LoadingSpinner";
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 { useHistory } from "react-router-dom";
import { JobState } from "@storyteller/components/src/jobs/JobStates";
import PremiumLock from "components/PremiumLock";
import OutputThumbnailImage from "../live_portrait/OutputThumbnailImage";
import SessionLsInferenceResultsList from "./SessionLsInferenceResultsList";
import ThumbnailMediaPicker from "../live_portrait/ThumbnailMediaPicker";
import { GenerateTts } from "./GenerateTts";
import { GetWeight } from "@storyteller/components/src/api/weights/GetWeight";
import { LipsyncTokenMap } from "./LipsyncTokens";
interface LivePortraitProps {
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
}
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
];
export default function LivePortrait({
sessionSubscriptionsWrapper,
}: LivePortraitProps) {
useDocumentTitle("Lip Sync AI. Free Video Animation");
const { enqueueInferenceJob } = useInferenceJobs();
const { loggedIn, sessionFetched } = 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");
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [cropArea, setCropArea] = useState<MediaFileCropArea>({
height: 0,
width: 0,
x: 0,
y: 0,
});
const [voiceModelTitle, setVoiceModelTitle] = useState<string | null>(null);
const [generatedVideoSrc, setGeneratedVideoSrc] = useState("");
const [sourceTokens, setSourceTokens] = useState<string[]>([
...PRECOMPUTED_SOURCE_TOKENS,
]);
const [audioTokens, setAudioTokens] = useState<string[]>([]);
const numberOfInitialSourceTokensRef = useRef(sourceTokens.length);
const numberOfInitialSourceTokens = numberOfInitialSourceTokensRef.current;
const numberOfInitialAudioTokensRef = useRef(audioTokens.length);
const numberOfInitialAudioTokens = numberOfInitialAudioTokensRef.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 [voiceToken, setVoiceToken] = useState<string | null>(null);
const [audioToken, setAudioToken] = useState<string | null>(null);
const location = useLocation();
const history = useHistory();
const handleAudioResultToken = (token: string | null) => {
setAudioToken(token);
};
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 = audioTokens.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 handleMotionSelect = (index: number) => {
// setIsUserUploaded(index >= numberOfInitialAudioTokens);
// setSelectedMotionIndex(index);
// setCurrentCombinationKey(getCombinationKey(selectedSourceIndex, index));
// };
const enqueueClick = () => {
// 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);
EnqueueLipsync("", {
creator_set_visibility: visibility,
audio_media_file_token: "",
maybe_crop: cropArea,
remove_watermark: removeWatermark,
image_or_video_media_file_token: "",
uuid_idempotency_token: uuidv4(),
}).then((res: EnqueueLipsyncResponse) => {
if (res.success && res.inference_job_token) {
enqueueInferenceJob(
res.inference_job_token,
FrontendInferenceJobType.Lipsync,
false
);
} else {
// @ts-ignore
window.dataLayer.push({
event: "enqueue_failure",
page: "/live-portrait",
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);
});
};
const renderVideoOrPlaceholder = () => {
const isCurrentlyGenerating = currentlyGeneratingList.some(
gen =>
gen.sourceIndex === selectedSourceIndex &&
gen.motionIndex === selectedMotionIndex
);
const currentProgress =
jobProgress[
getCombinationKey(selectedSourceIndex, selectedMotionIndex)
] || null;
const latestVideoSrc = getLatestVideoForCombination(
selectedSourceIndex,
selectedMotionIndex
);
if (latestVideoSrc && !isCurrentlyGenerating) {
return (
<video
loop
autoPlay
muted
playsInline
controls={true}
preload="auto"
key={latestVideoSrc}
>
<source src={latestVideoSrc} type="video/mp4" />
Your browser does not support the video tag.
</video>
);
} else if (isCurrentlyGenerating) {
return (
<div className="w-100 h-100 position-relative">
<div
className="position-absolute"
style={{
textAlign: "center",
width: "100%",
top: "50%",
transform: "translateY(-50%)",
}}
>
<h4 className="fw-medium">
<div className="d-flex flex-column align-items-center gap-3 justify-content-center">
<LoadingSpinner padding={false} />
{currentProgress !== null
? `Generating video... ${currentProgress}%`
: "Generating video..."}
</div>
</h4>
</div>
<OutputThumbnailImage
src={selectedSourceMediaLink || ""}
alt="Preview"
style={{ opacity: 0.15 }}
draggable={false}
/>
</div>
);
} else {
return (
<div className="w-100 h-100 position-relative">
<div
className="position-absolute"
style={{
textAlign: "center",
width: "100%",
top: "50%",
transform: "translateY(-50%)",
}}
>
{audioToken ? (
<h4 className="fw-medium">
Click{" "}
<b>
<FontAwesomeIcon icon={faSparkles} className="me-2 fs-6" />
Animate
</b>{" "}
to start generating
</h4>
) : (
<h4 className="fw-medium">
<b>
<FontAwesomeIcon
icon={faWaveformLines}
className="me-2 fs-6"
/>
Generate Audio
</b>{" "}
then click animate
</h4>
)}
</div>
<OutputThumbnailImage
src={selectedSourceMediaLink || ""}
alt="Preview"
style={{ opacity: 0.15 }}
draggable={false}
/>
</div>
);
}
};
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) {
setAudioTokens(prevTokens => {
const tokenIndex = prevTokens.indexOf(userMotionToken);
if (tokenIndex !== -1) {
setSelectedMotionIndex(tokenIndex);
setIsUserUploaded(tokenIndex >= numberOfInitialAudioTokens);
return prevTokens;
} else {
const updatedTokens = [...prevTokens, userMotionToken];
setSelectedMotionIndex(updatedTokens.length - 1);
setIsUserUploaded(true);
return updatedTokens;
}
});
setUserMotionToken("");
}
}, [userMotionToken, numberOfInitialAudioTokens]);
const isUserContent =
selectedSourceIndex >= numberOfInitialSourceTokens ||
selectedMotionIndex >= numberOfInitialAudioTokens;
const [selectedSourceMedia, setSelectedSourceMedia] = useState<{
[key: string]: any;
}>({});
const handleSelectedMediaChange = (media: any) => {
setSelectedSourceMedia(media);
};
const selectedSourceMediaLink = selectedSourceMedia?.public_bucket_path
? new BucketConfig().getGcsUrl(selectedSourceMedia.public_bucket_path)
: null;
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 = audioTokens.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) {
setAudioTokens(prevTokens => {
const tokenIndex = prevTokens.indexOf(motionToken);
if (tokenIndex !== -1) {
setSelectedMotionIndex(tokenIndex);
setIsUserUploaded(tokenIndex >= numberOfInitialAudioTokens);
return prevTokens;
} else {
const updatedTokens = [...prevTokens, motionToken];
setSelectedMotionIndex(updatedTokens.length - 1);
setIsUserUploaded(true);
return updatedTokens;
}
});
}
}, [
location.search,
numberOfInitialSourceTokens,
numberOfInitialAudioTokens,
]);
// 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]);
// const uploadFocusPointMotion = useMemo(() => {
// const queryParams = new URLSearchParams(location.search);
// const sourceToken = queryParams.get("source");
// const motionToken = queryParams.get("motion");
// if (sourceToken && !motionToken) {
// return true;
// } else if (!sourceToken && motionToken) {
// 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");
}
};
useEffect(() => {
const queryParams = new URLSearchParams(location.search);
setVoiceToken(queryParams.get("voice"));
if (voiceToken) {
const precomputedSourceToken = LipsyncTokenMap[voiceToken];
if (precomputedSourceToken) {
// Set the precomputed source token as the media
setSourceTokens(prevTokens => {
const tokenIndex = prevTokens.indexOf(precomputedSourceToken);
if (tokenIndex !== -1) {
setSelectedSourceIndex(tokenIndex);
setIsUserUploaded(tokenIndex >= numberOfInitialSourceTokens);
return prevTokens;
} else {
const updatedTokens = [...prevTokens, precomputedSourceToken];
setSelectedSourceIndex(updatedTokens.length - 1);
setIsUserUploaded(true);
return updatedTokens;
}
});
}
GetWeight(voiceToken, {})
.then(response => {
if (response && response.success) {
const title = response.title || null;
setVoiceModelTitle(title);
} else {
console.error(
"Failed to retrieve media or media has no title",
response
);
}
})
.catch(error => {
console.error("Error fetching media:", error);
});
}
}, [location.search, numberOfInitialSourceTokens, voiceToken]);
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 Lip Sync
</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">
<FontAwesomeIcon icon={faLips} className="me-3 fs-2" />
Lip Sync
</h1>
<h2 className="fs-5 opacity-75 fw-semibold pb-2">
Make your characters really speak with lip sync and text to speech
</h2>
{voiceModelTitle ? (
<Panel
style={{ marginBottom: "2.5rem" }}
className="panel-inner p-3 rounded"
>
<div className="d-flex align-items-center gap-2">
<FontAwesomeIcon icon={faMicrophone} className="me-1" />
<h3 className="fs-6 fw-semibold mb-0">
{voiceModelTitle ? `Current Voice: ${voiceModelTitle}` : null}
</h3>
</div>
</Panel>
) : null}
{/* <hr style={{ marginBottom: "2.5rem" }} /> */}
{!loggedIn && (
<div style={{ marginBottom: "2.5rem" }}>{signupCTA}</div>
)}
<div>
<div className="row gx-0 gy-4">
<div className="col-12 col-lg-3 d-flex gap-3 flex-column align-items-center pt-lg-5">
<ThumbnailMediaPicker
mediaTokens={sourceTokens}
selectedIndex={selectedSourceIndex}
title="Source Image/Video"
description="This is what your final video will look like."
badgeLabel="Source Media"
stepNumber={1}
onSelectedMediaChange={handleSelectedMediaChange}
showUploadButton={false}
showThumbnails={false}
stepAlwaysOnTop={true}
/>
</div>
<div className="col-12 col-lg-1 d-flex justify-content-center ls-section-between">
<FontAwesomeIcon
icon={faPlus}
className="display-3 opacity-75"
/>
</div>
<div className="col-12 col-lg-3 d-flex gap-3 flex-column pt-lg-5">
<GenerateTts
weightToken={voiceToken}
onResultToken={handleAudioResultToken}
/>
</div>
<div className="col-12 col-lg-1 d-flex justify-content-center ls-section-between">
<FontAwesomeIcon
icon={faEquals}
className="display-3 opacity-75 d-none d-lg-block"
/>
<FontAwesomeIcon
icon={faArrowDown}
className="display-3 opacity-75 d-block d-lg-none"
/>
</div>
<div className="col-12 col-lg-4 d-flex gap-3 flex-column">
<div className="lp-media">
{renderVideoOrPlaceholder()}
<div className="lp-tag">
<div className="d-flex gap-2 w-100">
<Badge
label="Output Video"
color="ultramarine"
overlay={true}
/>
{!isUserContent && (
<Badge
label="Pre-generated Example"
color="gray"
overlay={true}
/>
)}
</div>
</div>
</div>
<div className="d-flex flex-column gap-4">
<div className="d-flex gap-2">
<Button
icon={isUserContent ? faSparkles : undefined}
label={
!loggedIn && isUserContent
? "Sign Up and Animate"
: isUserContent
? generatedVideoSrc
? "Re-animate"
: "Animate"
: !loggedIn
? "Sign up now to Animate"
: "Upload your media to generate"
}
onClick={
loggedIn
? enqueueClick
: () => history.push("/signup?redirect=/lipsync")
}
className="flex-grow-1"
// disabled={!isUserContent}
isLoading={isEnqueuing || isGenerating}
disabled={(!isUserContent && loggedIn) || !audioToken}
/>
<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 mb-4">
<PremiumLock
sessionSubscriptionsWrapper={sessionSubscriptionsWrapper}
lockPosition="top"
requiredPlan="pro"
plural={true}
>
<div className="d-flex gap-3">
<Checkbox
label={"Make Private"}
onChange={() => {
setVisibility(prevVisibility =>
prevVisibility === "private"
? "public"
: "private"
);
}}
checked={visibility === "private"}
className="mb-0"
/>
<Checkbox
label={"Remove Watermark"}
onChange={() => {
setRemoveWatermark(
prevRemoveWatermark => !prevRemoveWatermark
);
}}
checked={removeWatermark}
className="mb-0"
/>
</div>
</PremiumLock>
{/* {!hasPremium && (
<div className="d-flex">
<Button
variant="link"
label="Upgrade to Premium to use features above"
icon={faStars}
to="/pricing"
/>
</div>
)} */}
</div>
</div>
</div>
{loggedIn && (
<div className="mt-5 pt-3 order-3 d-none">
<Label label="Latest Outputs" />
<div>
<SessionLsInferenceResultsList
sessionSubscriptionsWrapper={sessionSubscriptionsWrapper}
onJobTokens={handleJobTokens}
addSourceToken={(newToken: string) =>
setSourceTokens(prevTokens =>
prevTokens.includes(newToken)
? prevTokens
: [...prevTokens, newToken]
)
}
addMotionToken={(newToken: string) =>
setAudioTokens(prevTokens =>
prevTokens.includes(newToken)
? prevTokens
: [...prevTokens, newToken]
)
}
onJobClick={handleJobClick}
onJobProgress={handleJobProgress}
onJobStateChange={handleJobStateChange}
/>
</div>
</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>
</>
);
}
@@ -0,0 +1,36 @@
import React, { useCallback, useState } from "react";
import Wavesurfer from "react-wavesurfer.js";
interface Props {
filename: string;
play: boolean;
onFinish?: () => void;
}
export default function LipsyncAudioPlayer(props: Props) {
const [position, setPosition] = useState(0);
const handleFinish = useCallback(() => {
setPosition(0);
props.onFinish && props.onFinish();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.onFinish]);
return (
<div className="flex-grow-1 h-100 overflow-hidden w-100">
<Wavesurfer
key={props.filename}
onFinish={handleFinish}
pos={position}
src={props.filename || ""}
height={100}
progressColor="#fc8481"
waveColor="#b09e9e"
cursorColor="#fc8481"
playing={props.play}
responsive={true}
normalize={true}
/>
</div>
);
}
@@ -0,0 +1,3 @@
export const LipsyncTokenMap: { [key: string]: string } = {
weight_hz7g8f1j4psrsw2sv67e4y61q: "m_zx1fyp6t7n8b46swz087xkapa7s2vs", // Mariano Closs
};
@@ -0,0 +1,244 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { useInferenceJobs } from "hooks";
import { LivePortraitDetails } from "@storyteller/components/src/api/model_inference/GetModelInferenceJobStatus";
import { GetMedia } from "@storyteller/components/src/api/media_files/GetMedia";
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
import { JobState } from "@storyteller/components/src/jobs/JobStates";
import moment from "moment";
import { Link } from "react-router-dom";
import LoadingSpinner from "components/common/LoadingSpinner";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowTurnUp } from "@fortawesome/pro-solid-svg-icons";
interface SessionLpInferenceResultsListProps {
sessionSubscriptionsWrapper: any;
onJobTokens: (
maybeResultToken: string,
jobToken: string,
createdAt: Date,
maybeLivePortraitDetails?: LivePortraitDetails
) => void;
addSourceToken: (token: string) => void;
addMotionToken: (token: string) => void;
onJobClick: (job: InferenceJob) => void;
onJobProgress: (progress: number | null) => void;
onJobStateChange: (jobToken: string, jobState: JobState) => void;
}
export default function SessionLpInferenceResultsList({
sessionSubscriptionsWrapper,
onJobTokens,
addMotionToken,
addSourceToken,
onJobClick,
onJobProgress,
onJobStateChange,
}: SessionLpInferenceResultsListProps) {
const { inferenceJobsByCategory } = useInferenceJobs();
const hasInitialized = useRef(false);
const lastProgressRef = useRef<{ [key: string]: number | null }>({});
const livePortraitJobs = useMemo(() => {
return (
inferenceJobsByCategory.get(FrontendInferenceJobType.LivePortrait) || []
);
}, [inferenceJobsByCategory]);
const lastProcessedJobToken = useRef<string | null>(null);
useEffect(() => {
if (!hasInitialized.current) {
hasInitialized.current = true;
} else {
livePortraitJobs.forEach((job: InferenceJob) => {
if (
job.maybeResultToken &&
job.jobToken !== lastProcessedJobToken.current
) {
const livePortraitDetails = job.maybeLivePortraitDetails;
if (livePortraitDetails) {
const { source_media_file_token, face_driver_media_file_token } =
livePortraitDetails;
addSourceToken(source_media_file_token);
addMotionToken(face_driver_media_file_token);
lastProcessedJobToken.current = job.jobToken;
onJobTokens(
job.maybeResultToken,
job.jobToken,
job.createdAt,
livePortraitDetails
);
}
}
});
}
}, [livePortraitJobs, onJobTokens, addSourceToken, addMotionToken]);
useEffect(() => {
livePortraitJobs.forEach((job: InferenceJob) => {
const currentProgress = job.progressPercentage;
// Early return if nothing has changed
if (
(job.jobState === JobState.STARTED && currentProgress === null) ||
(job.jobState !== JobState.STARTED &&
job.jobState !== JobState.COMPLETE_SUCCESS &&
job.jobState !== JobState.COMPLETE_FAILURE)
) {
return;
}
// Progress handling for STARTED jobs
if (job.jobState === JobState.STARTED) {
if (lastProgressRef.current[job.jobToken] !== currentProgress) {
lastProgressRef.current[job.jobToken] = currentProgress;
onJobProgress(currentProgress);
}
return;
}
// Handling COMPLETE_SUCCESS or COMPLETE_FAILURE states
if (
(job.jobState === JobState.COMPLETE_SUCCESS ||
job.jobState === JobState.COMPLETE_FAILURE) &&
lastProgressRef.current[job.jobToken] !== null
) {
lastProgressRef.current[job.jobToken] = null;
onJobProgress(null);
onJobStateChange(job.jobToken, job.jobState);
}
});
}, [livePortraitJobs, onJobProgress, onJobStateChange]);
const [mediaSrc, setMediaSrc] = useState<{ [key: string]: string }>({});
useEffect(() => {
const fetchMedia = async (token: string) => {
try {
const response = await GetMedia(token, {});
const publicBucketPath = response.media_file?.public_bucket_path || "";
setMediaSrc(prev => ({ ...prev, [token]: publicBucketPath }));
} catch (error) {
console.error("Error fetching media:", error);
}
};
livePortraitJobs.forEach((job: InferenceJob) => {
const token = job.maybeLivePortraitDetails?.source_media_file_token;
if (token && !mediaSrc[token]) {
fetchMedia(token);
}
});
}, [livePortraitJobs, mediaSrc]);
const jobStateTextMap: { [key in JobState]: string } = {
[JobState.UNKNOWN]: "Unknown",
[JobState.PENDING]: "Pending",
[JobState.STARTED]: "Generating",
[JobState.COMPLETE_SUCCESS]: "Completed",
[JobState.COMPLETE_FAILURE]: "Completed (Failure)",
[JobState.ATTEMPT_FAILED]: "Attempt Failed",
[JobState.DEAD]: "Dead",
[JobState.CANCELED_BY_USER]: "Canceled by User",
};
const jobContent = (
<div>
{livePortraitJobs.length > 0 ? (
<div className="row g-3">
{livePortraitJobs
.slice(0, 4)
.map((job: InferenceJob, key: number) => (
<div
key={key}
onClick={() => {
onJobClick(job);
}}
className="col-12 col-lg-3"
>
<div className="lp-jobs-list">
<div
className="ratio ratio-1x1 overflow-hidden rounded"
style={{ width: "70px" }}
>
<img
src={
job.maybeLivePortraitDetails?.source_media_file_token
? (() => {
const mediaPath =
mediaSrc[
job.maybeLivePortraitDetails
.source_media_file_token
] || "";
const isVideo = mediaPath.endsWith(".mp4");
const finalPath = isVideo
? `${mediaPath}-thumb.jpg`
: mediaPath;
return new BucketConfig().getGcsUrl(finalPath);
})()
: ""
}
alt="Job Thumbnail"
className="object-fit-cover w-100 h-100 rounded"
/>
</div>
<div className="d-flex flex-column flex-grow-1">
<div className="d-flex gap-2 align-items-center">
{(job.jobState === JobState.PENDING ||
job.jobState === JobState.STARTED) && (
<LoadingSpinner thin={true} size={14} padding={false} />
)}
<span className="fw-semibold">
{jobStateTextMap[job.jobState as JobState]}
</span>
</div>
<span className="fw-normal opacity-75 fs-7">
{moment(job.createdAt).fromNow()}
</span>
<div className="d-flex">
{job.maybeResultToken ? (
<Link
className="fs-7 d-flex align-items-center gap-1 mt-1"
to={`/media/${job.maybeResultToken}`}
>
More Details
</Link>
) : (
<div className="fs-7 opacity-50 fw-medium mt-1">
{job.progressPercentage}% complete
</div>
)}
</div>
</div>
<FontAwesomeIcon
icon={faArrowTurnUp}
className="pe-2 fs-5 opacity-75"
/>
</div>
</div>
))}
</div>
) : (
<div
className="lp-jobs-list no-hover d-flex align-items-center justify-content-center"
style={{ height: "94px" }}
>
<span className="fw-medium opacity-75">
Your latest lip sync generations will appear here.
</span>
</div>
)}
</div>
);
return <>{jobContent}</>;
}
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react";
interface ThumbnailItemProps {
index: number;
selectedIndex: number;
handleThumbnailClick: (index: number) => void;
handleThumbnailClick?: (index: number) => void;
poster?: string;
mediaType?: string;
}
@@ -48,7 +48,7 @@ const ThumbnailItem: React.FC<ThumbnailItemProps> = ({
<div className="col-3" key={index}>
<div
className={`lp-thumbnail ${index === selectedIndex ? "active" : ""}`}
onClick={() => handleThumbnailClick(index)}
onClick={() => handleThumbnailClick && handleThumbnailClick(index)}
>
{poster ? (
mediaType === "image" ? (
@@ -21,7 +21,7 @@ import { useSession } from "hooks";
interface ThumbnailMediaPickerProps {
mediaTokens: string[];
selectedIndex: number;
handleThumbnailClick: (index: number) => void;
handleThumbnailClick?: (index: number) => void;
title?: string;
description?: string;
badgeLabel?: string;
@@ -39,6 +39,10 @@ interface ThumbnailMediaPickerProps {
onSelectedMediaChange?: (media: any) => void;
uploadFocusPoint?: boolean;
uploadButtonText?: string;
showThumbnails?: boolean;
showUploadButton?: boolean;
showStep?: boolean;
stepAlwaysOnTop?: boolean;
}
interface MediaData {
@@ -62,6 +66,10 @@ const ThumbnailMediaPicker: React.FC<ThumbnailMediaPickerProps> = React.memo(
onSelectedMediaChange,
uploadFocusPoint,
uploadButtonText = "Upload your own media",
showThumbnails = true,
showUploadButton = true,
showStep = true,
stepAlwaysOnTop = false,
}) => {
const [isCropping, setIsCropping] = useState(false);
const [mediaData, setMediaData] = useState<{ [key: string]: any }>({});
@@ -146,8 +154,10 @@ const ThumbnailMediaPicker: React.FC<ThumbnailMediaPickerProps> = React.memo(
);
return (
<div className="d-flex gap-3 flex-column">
<div className="lp-media order-4 order-lg-1">
<div className="d-flex gap-3 flex-column w-100 h-100">
<div
className={`lp-media order-4 ${stepAlwaysOnTop ? "" : "order-lg-1"}`}
>
<div className="lp-tag">
<div>
{!isCropping ? (
@@ -232,59 +242,67 @@ const ThumbnailMediaPicker: React.FC<ThumbnailMediaPickerProps> = React.memo(
)}
</div>
<div className="order-1 order-lg-2">
<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>
<div className="row g-2 order-2 order-lg-3 position-relative">
{paginatedMediaTokens.map((token, index) => {
const media = mediaData[token];
const mediaLink = media?.public_bucket_path
? new BucketConfig().getGcsUrl(media.public_bucket_path)
: null;
return (
<ThumbnailItem
key={index}
index={index + currentPage * itemsPerPage}
selectedIndex={selectedIndex}
handleThumbnailClick={handleThumbnailClick}
poster={mediaLink || ""}
mediaType={media?.media_type}
/>
);
})}
{mediaTokens.length > itemsPerPage && (
<div className="thumbnail-pagination">
<FontAwesomeIcon
icon={faChevronLeft}
onClick={handlePreviousPage}
className="thumbnail-pagination-icon left-arrow"
/>
<FontAwesomeIcon
icon={faChevronRight}
onClick={handleNextPage}
className="thumbnail-pagination-icon right-arrow"
/>
{showStep && (
<div
className={`order-1 ${stepAlwaysOnTop ? "" : "order-lg-2"}`.trim()}
>
<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>
)}
</div>
<Button
icon={faUpload}
label={uploadButtonText}
variant="action"
className="order-3 order-lg-4"
onClick={onUploadClick}
focusPoint={uploadFocusPoint}
disabled={!loggedIn}
/>
<p className="fw-medium fs-7 opacity-75">{description}</p>
</div>
)}
{showThumbnails && (
<div className="row g-2 order-2 order-lg-3 position-relative">
{paginatedMediaTokens.map((token, index) => {
const media = mediaData[token];
const mediaLink = media?.public_bucket_path
? new BucketConfig().getGcsUrl(media.public_bucket_path)
: null;
return (
<ThumbnailItem
key={index}
index={index + currentPage * itemsPerPage}
selectedIndex={selectedIndex}
handleThumbnailClick={handleThumbnailClick}
poster={mediaLink || ""}
mediaType={media?.media_type}
/>
);
})}
{mediaTokens.length > itemsPerPage && (
<div className="thumbnail-pagination">
<FontAwesomeIcon
icon={faChevronLeft}
onClick={handlePreviousPage}
className="thumbnail-pagination-icon left-arrow"
/>
<FontAwesomeIcon
icon={faChevronRight}
onClick={handleNextPage}
className="thumbnail-pagination-icon right-arrow"
/>
</div>
)}
</div>
)}
{showUploadButton && (
<Button
icon={faUpload}
label={uploadButtonText}
variant="action"
className="order-3 order-lg-4"
onClick={onUploadClick}
focusPoint={uploadFocusPoint}
disabled={!loggedIn}
/>
)}
</div>
);
}