mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 01:38:44 +00: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
|
||||
};
|
||||
+244
@@ -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" ? (
|
||||
|
||||
+72
-54
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user