new live portrait 3rd push - final (#670)

* update

* updates final
This commit is contained in:
BFlat authored and GitHub committed 2024-08-16 18:00:16 +07:00
1 parent 22f72e60ee
commit 125b2a999a
11 files changed
+520 -162

No files matched your search

@@ -25,6 +25,8 @@ export interface RequestDetails {
maybe_model_title?: string;
maybe_raw_inference_text?: string;
maybe_live_portrait_details?: LivePortraitDetails;
}
export interface StatusDetails {
@@ -42,6 +44,11 @@ export interface ResultDetails {
maybe_public_bucket_media_path?: string;
}
export type LivePortraitDetails = {
source_media_file_token: string;
face_driver_media_file_token: string;
};
export interface GetModelInferenceJobStatusErrorResponse {
success: boolean;
}
@@ -1,4 +1,7 @@
import { ModelInferenceJobStatus } from "../api/model_inference/GetModelInferenceJobStatus";
import {
LivePortraitDetails,
ModelInferenceJobStatus,
} from "../api/model_inference/GetModelInferenceJobStatus";
import { JobState, jobStateFromString } from "./JobStates";
// Type of inference job (specified by the frontend, not backend)
@@ -29,6 +32,7 @@ export type JobListOptions = FrontendInferenceJobType | AllInferenceJobs;
export class InferenceJob {
// PK
jobToken: string;
createdAt: Date;
frontendJobType: FrontendInferenceJobType;
@@ -43,6 +47,7 @@ export class InferenceJob {
maybeModelToken?: string;
maybeModelTitle?: string;
maybeRawInferenceText?: string;
maybeLivePortraitDetails?: LivePortraitDetails;
// Result
maybeResultType: string | undefined | null;
@@ -53,6 +58,7 @@ export class InferenceJob {
constructor(
// PK
jobToken: string,
createdAt: Date,
// Frontend state
frontendJobType: FrontendInferenceJobType,
// Status
@@ -65,6 +71,7 @@ export class InferenceJob {
maybeModelToken: string | undefined = undefined,
maybeModelTitle: string | undefined = undefined,
maybeRawInferenceText: string | undefined = undefined,
maybeLivePortraitDetails: LivePortraitDetails | undefined = undefined,
// Result
maybeResultEntityType: string | undefined | null = null,
maybeResultEntityToken: string | undefined | null = null,
@@ -72,6 +79,7 @@ export class InferenceJob {
maybeFailureCategory: string | undefined | null = null
) {
this.jobToken = jobToken;
this.createdAt = createdAt;
this.frontendJobType = frontendJobType;
this.jobState = jobStateFromString(status);
this.maybeExtraStatusDescription = maybeExtraStatusDescription;
@@ -90,6 +98,8 @@ export class InferenceJob {
this.maybeRawInferenceText = maybeRawInferenceText;
this.maybeLivePortraitDetails = maybeLivePortraitDetails;
if (!!maybeResultEntityType) {
this.maybeResultType = maybeResultEntityType;
}
@@ -110,6 +120,7 @@ export class InferenceJob {
): InferenceJob {
return new InferenceJob(
response.job_token,
response.created_at,
frontendJobType,
response.status.status,
response.status.maybe_extra_status_description || null,
@@ -119,6 +130,7 @@ export class InferenceJob {
response.request.maybe_model_token,
response.request.maybe_model_title,
response.request.maybe_raw_inference_text,
response.request.maybe_live_portrait_details,
response.maybe_result?.entity_type,
response.maybe_result?.entity_token,
response.maybe_result?.maybe_public_bucket_media_path,
@@ -145,6 +145,8 @@ export default function VideoCard({
const getLabel = (data: any) => {
if (data.origin_product_category === "face_animator") {
return "Lipsync";
} else if (data.origin_product_category === "face_mirror") {
return "Live Portrait";
} else if (data.origin_product_category === "workflow") {
return "Workflow";
} else if (data.origin_product_category === "unknown") {
@@ -40,6 +40,8 @@ const JobCategoryToType = (jobCategory: string) => {
return FrontendInferenceJobType.ConvertFbxtoGltf;
case "convert_bvh_to_workflow":
return FrontendInferenceJobType.EngineComposition;
case "live_portrait":
return FrontendInferenceJobType.LivePortrait;
default:
return FrontendInferenceJobType.Unknown;
}
@@ -95,23 +97,23 @@ export default function useInferenceJobsPolling({
frontendJobType: FrontendInferenceJobType
) => {
const categoryArray = categoryMap.get(frontendJobType) || [];
if (user) {
categoryMap.set(frontendJobType, [
...categoryArray,
updatedJob,
]);
} else {
const inArray = categoryArray.find((job,i) => job.jobToken === updatedJob.jobToken );
if (user) {
categoryMap.set(frontendJobType, [...categoryArray, updatedJob]);
} else {
const inArray = categoryArray.find(
(job, i) => job.jobToken === updatedJob.jobToken
);
if (!inArray) {
categoryMap.set(frontendJobType, [
updatedJob,
...categoryArray,
]);
categoryMap.set(frontendJobType, [updatedJob, ...categoryArray]);
} else {
categoryMap.set(frontendJobType,categoryArray.map((job,i) => job.jobToken === updatedJob.jobToken ? updatedJob : job) )
categoryMap.set(
frontendJobType,
categoryArray.map((job, i) =>
job.jobToken === updatedJob.jobToken ? updatedJob : job
)
);
}
}
};
@@ -150,7 +152,10 @@ export default function useInferenceJobsPolling({
}
});
const noSessionJobs = async (currentQueue: InferenceJob[], currentCategoryMap: CategoryMap) => {
const noSessionJobs = async (
currentQueue: InferenceJob[],
currentCategoryMap: CategoryMap
) => {
let categoryMap = new Map(currentCategoryMap);
Promise.all(
currentQueue.map(async (job: InferenceJob) => {
@@ -165,18 +170,17 @@ export default function useInferenceJobsPolling({
}
);
})
).then((updatedJobs) => {
).then(updatedJobs => {
if (updatedJobs.length) {
updateState(updatedJobs, categoryMap)
updateState(updatedJobs, categoryMap);
}
});
};
const onTick = async ({
eventProps: { inferenceJobs: currentQueue, byCategory: currentCategoryMap },
}: {
eventProps: { inferenceJobs: InferenceJob[], byCategory: CategoryMap };
eventProps: { inferenceJobs: InferenceJob[]; byCategory: CategoryMap };
}) => {
if (user) {
sessionJobs();
@@ -187,14 +191,15 @@ export default function useInferenceJobsPolling({
const enqueueInferenceJob = (
jobToken: string,
frontendJobType: FrontendInferenceJobType
frontendJobType: FrontendInferenceJobType,
createdAt: Date
) => {
onTick({ eventProps: { byCategory, inferenceJobs: [] } });
if (user) {
// reserving this space for later uses
} else {
keepAliveSet(false);
const newJob = new InferenceJob(jobToken, frontendJobType);
const newJob = new InferenceJob(jobToken, createdAt, frontendJobType);
inferenceJobsSet([newJob, ...(inferenceJobs || [])]);
}
@@ -207,7 +212,9 @@ export default function useInferenceJobsPolling({
clearJobsStatusSet(FetchStatus.in_progress);
DismissFinishedJobs("", {}).then((res: DismissFinishedJobsResponse) => {
if (res.success) {
onTick({ eventProps: { byCategory: newJobCategoryMap(), inferenceJobs: [] } });
onTick({
eventProps: { byCategory: newJobCategoryMap(), inferenceJobs: [] },
});
keepAliveSet(true);
clearJobsStatusSet(FetchStatus.ready);
}
@@ -138,3 +138,35 @@
}
}
}
.lp-jobs-list {
background-color: lighten($panel, 5%);
border-radius: 0.5rem;
transition: all 0.2s;
padding: 10px;
cursor: pointer;
align-items: center;
display: flex;
gap: 12px;
border: 2px solid transparent;
&:hover {
background-color: lighten($panel, 10%);
border: 2px solid lighten($panel, 15%);
}
img {
border: lighten($panel, 15%) solid 2px;
background-color: rgba($white, 0.06);
}
&.no-hover {
background-color: lighten($panel, 3%) !important;
&:hover {
cursor: default;
background-color: lighten($panel, 3%) !important;
border: 2px solid lighten($panel, 3%) !important;
}
}
}
@@ -33,7 +33,10 @@ import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
import { v4 as uuidv4 } from "uuid";
import { useInferenceJobs, useModal } from "hooks";
import { FrontendInferenceJobType } from "@storyteller/components/src/jobs/InferenceJob";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import { AITools } from "components/marketing";
import { EntityInput } from "components/entities";
@@ -42,6 +45,7 @@ import LoadingSpinner from "components/common/LoadingSpinner";
import SessionLpInferenceResultsList from "./SessionLpInferenceResultsList";
import { GetMedia } from "@storyteller/components/src/api/media_files/GetMedia";
import { useLocation } from "react-router-dom";
import { LivePortraitDetails } from "@storyteller/components/src/api/model_inference/GetModelInferenceJobStatus";
interface LivePortraitProps {
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
@@ -50,7 +54,11 @@ interface LivePortraitProps {
interface GeneratedVideo {
sourceIndex: number;
motionIndex: number;
sourceToken: string;
motionToken: string;
videoSrc: string;
jobToken: string;
createdAt: Date;
}
export default function LivePortrait({
@@ -70,7 +78,6 @@ export default function LivePortrait({
y: 0,
});
const hasPremium = sessionSubscriptionsWrapper.hasPaidFeatures();
const [videoSrc, setVideoSrc] = useState("");
const [generatedVideoSrc, setGeneratedVideoSrc] = useState("");
const [sourceTokens, setSourceTokens] = useState<string[]>([
"m_2xrse9799wvy8hkv8tbxqxct8089t7",
@@ -88,6 +95,10 @@ export default function LivePortrait({
const [isUserUploaded, setIsUserUploaded] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [jobProcessedTokens, setJobProcessedTokens] = useState<string[]>([]);
const [currentlyGeneratingList, setCurrentlyGeneratingList] = useState<
{ sourceIndex: number; motionIndex: number }[]
>([]);
const location = useLocation();
@@ -103,20 +114,61 @@ export default function LivePortrait({
[]
);
const handleJobClick = (job: InferenceJob) => {
const livePortraitDetails = job.maybeLivePortraitDetails;
if (livePortraitDetails) {
const sourceIndex = sourceTokens.indexOf(
livePortraitDetails.source_media_file_token
);
const motionIndex = motionTokens.indexOf(
livePortraitDetails.face_driver_media_file_token
);
if (sourceIndex !== -1) {
setSelectedSourceIndex(sourceIndex);
} else {
setSourceTokens(prevTokens => [
...prevTokens,
livePortraitDetails.source_media_file_token,
]);
setSelectedSourceIndex(sourceTokens.length);
}
if (motionIndex !== -1) {
setSelectedMotionIndex(motionIndex);
} else {
setMotionTokens(prevTokens => [
...prevTokens,
livePortraitDetails.face_driver_media_file_token,
]);
setSelectedMotionIndex(motionTokens.length);
}
}
};
const handleSourceSelect = (index: number) => {
console.log("Thumbnail clicked:", index);
setIsUserUploaded(index >= numberOfInitialSourceTokens);
setSelectedSourceIndex(index);
};
const handleMotionSelect = (index: number) => {
console.log("Thumbnail clicked:", index);
setIsUserUploaded(index >= numberOfInitialMotionTokens);
setSelectedMotionIndex(index);
};
const enqueueClick = () => {
setIsEnqueuing(true);
// 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);
EnqueueFaceMirror("", {
creator_set_visibility: visibility,
face_driver_media_file_token: motionTokens[selectedMotionIndex],
@@ -128,7 +180,7 @@ export default function LivePortrait({
if (res.success && res.inference_job_token) {
enqueueInferenceJob(
res.inference_job_token,
FrontendInferenceJobType.TextToSpeech,
FrontendInferenceJobType.LivePortrait,
false
);
@@ -136,43 +188,29 @@ export default function LivePortrait({
} else {
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 = () => {
if (generatedVideoSrc) {
return (
<video
loop
autoPlay
muted
playsInline
controls={true}
preload="auto"
key={generatedVideoSrc}
>
<source src={generatedVideoSrc} type="video/mp4" />
Your browser does not support the video tag.
</video>
);
} else if (videoSrc) {
return (
<video
loop
autoPlay
muted
playsInline
controls={true}
preload="auto"
key={videoSrc}
>
<source src={videoSrc} type="video/mp4" />
Your browser does not support the video tag.
</video>
);
} else if (isGenerating) {
// Check if the current combination is being generated
const isCurrentlyGenerating = currentlyGeneratingList.some(
gen =>
gen.sourceIndex === selectedSourceIndex &&
gen.motionIndex === selectedMotionIndex
);
if (isCurrentlyGenerating) {
// Show "Generating Video..." if the combination is currently generating
return (
<div className="w-100 h-100 position-relative">
<div
@@ -199,7 +237,40 @@ export default function LivePortrait({
/>
</div>
);
} else if (generatedVideoSrc) {
// Show dynamically generated video if available
return (
<video
loop
autoPlay
muted
playsInline
controls={true}
preload="auto"
key={generatedVideoSrc}
>
<source src={generatedVideoSrc} type="video/mp4" />
Your browser does not support the video tag.
</video>
);
} else if (getPrecomputedVideoSrc()) {
// Show precomputed video if it exists
return (
<video
loop
autoPlay
muted
playsInline
controls={true}
preload="auto"
key={getPrecomputedVideoSrc()}
>
<source src={getPrecomputedVideoSrc()} type="video/mp4" />
Your browser does not support the video tag.
</video>
);
} else {
// Show "Click to Animate" if nothing is generating or precomputed
return (
<div className="w-100 h-100 position-relative">
<div
@@ -239,20 +310,6 @@ export default function LivePortrait({
return "";
}, [selectedSourceIndex, selectedMotionIndex, precomputedVideos]);
useEffect(() => {
if (isUserUploaded) {
setVideoSrc("");
} else {
const newSrc = getPrecomputedVideoSrc();
setVideoSrc(newSrc);
}
}, [
selectedSourceIndex,
selectedMotionIndex,
isUserUploaded,
getPrecomputedVideoSrc,
]);
useEffect(() => {
if (userSourceToken) {
setSourceTokens(prevTokens => {
@@ -307,9 +364,9 @@ export default function LivePortrait({
};
const motionInputProps = {
accept: ["image", "video"],
accept: ["video"],
className: "w-100",
label: "Upload Source Media (Image or Video)",
label: "Upload Motion Reference Video",
onChange: ({ target }: { target: any }) => {
setUserMotionToken(target.value);
close();
@@ -347,10 +404,17 @@ export default function LivePortrait({
const handleJobTokens = async (
maybeResultToken: string,
jobToken: string
jobToken: string,
createdAt: Date,
livePortraitDetails?: LivePortraitDetails
) => {
console.log("Maybe Result Token:", maybeResultToken);
console.log("Job Token:", jobToken);
if (jobProcessedTokens.includes(jobToken)) {
return;
}
if (!livePortraitDetails) {
return;
}
const response = await GetMedia(maybeResultToken, {});
@@ -363,31 +427,69 @@ export default function LivePortrait({
response.media_file.public_bucket_path
);
const isSourceValid = selectedSourceIndex < sourceTokens.length;
const isMotionValid = selectedMotionIndex < motionTokens.length;
const sourceIndex = sourceTokens.indexOf(
livePortraitDetails.source_media_file_token
);
const motionIndex = motionTokens.indexOf(
livePortraitDetails.face_driver_media_file_token
);
if (isSourceValid && isMotionValid) {
const generatedVideo = {
sourceIndex: selectedSourceIndex,
motionIndex: selectedMotionIndex,
videoSrc: mediaLink,
};
const newGeneratedVideo = {
sourceIndex,
motionIndex,
sourceToken: livePortraitDetails.source_media_file_token,
motionToken: livePortraitDetails.face_driver_media_file_token,
videoSrc: mediaLink,
jobToken, // Store the job token with the video
createdAt,
};
// Update the state with the new combination
setGeneratedVideos(prevGeneratedVideos => [
...prevGeneratedVideos,
generatedVideo,
]);
setGeneratedVideos(prevGeneratedVideos => {
const existingVideoIndex = prevGeneratedVideos.findIndex(
video =>
video.sourceIndex === newGeneratedVideo.sourceIndex &&
video.motionIndex === newGeneratedVideo.motionIndex
);
// Only set generatedVideoSrc if it matches the current selected indices
if (
selectedSourceIndex === generatedVideo.sourceIndex &&
selectedMotionIndex === generatedVideo.motionIndex
) {
setGeneratedVideoSrc(mediaLink);
setIsGenerating(false);
if (existingVideoIndex !== -1) {
const existingVideo = prevGeneratedVideos[existingVideoIndex];
// Replace the existing video only if the new jobToken is more recent
if (
new Date(existingVideo.createdAt).getTime() <
new Date(newGeneratedVideo.createdAt).getTime()
) {
const updatedVideos = [...prevGeneratedVideos];
updatedVideos[existingVideoIndex] = newGeneratedVideo;
return updatedVideos;
}
return prevGeneratedVideos;
} else {
// Add the new video if it doesn't already exist
return [...prevGeneratedVideos, newGeneratedVideo];
}
});
// Set the new video for display if it's the latest
if (
selectedSourceIndex === newGeneratedVideo.sourceIndex &&
selectedMotionIndex === newGeneratedVideo.motionIndex
) {
setGeneratedVideoSrc(mediaLink);
setIsGenerating(false);
// Remove from generating list as job is complete
setCurrentlyGeneratingList(prevList =>
prevList.filter(
gen =>
gen.sourceIndex !== newGeneratedVideo.sourceIndex ||
gen.motionIndex !== newGeneratedVideo.motionIndex
)
);
}
// Mark the job as processed
setJobProcessedTokens(prevTokens => [...prevTokens, jobToken]);
} else {
console.error(
"Failed to retrieve media or media has no public bucket path",
@@ -398,43 +500,45 @@ export default function LivePortrait({
};
useEffect(() => {
const getVideoForCombination = (
const getLatestVideoForCombination = (
sourceIndex: number,
motionIndex: number
) => {
const video = generatedVideos.find(
// Filter generated videos by sourceIndex and motionIndex
const matchingVideos = generatedVideos.filter(
video =>
video.sourceIndex === sourceIndex && video.motionIndex === motionIndex
);
return video ? video.videoSrc : null;
// Sort the videos by createdAt to get the latest one
const sortedVideos = matchingVideos.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
// Return the latest video source if it exists
return sortedVideos.length > 0 ? sortedVideos[0].videoSrc : null;
};
const existingVideoSrc = getVideoForCombination(
const latestVideoSrc = getLatestVideoForCombination(
selectedSourceIndex,
selectedMotionIndex
);
if (existingVideoSrc) {
// Show the generated video that matches the current selection
setGeneratedVideoSrc(existingVideoSrc);
if (latestVideoSrc) {
setGeneratedVideoSrc(latestVideoSrc);
setIsGenerating(false);
} else if (!isUserUploaded) {
// Show the precomputed video if no user-uploaded content is present
const newSrc = getPrecomputedVideoSrc();
setVideoSrc(newSrc);
setGeneratedVideoSrc("");
setIsGenerating(false);
} else {
// Clear the videoSrc and generatedVideoSrc if no match is found
setVideoSrc("");
setGeneratedVideoSrc("");
}
}, [
selectedSourceIndex,
selectedMotionIndex,
isUserUploaded,
generatedVideos,
getPrecomputedVideoSrc,
isUserUploaded,
]);
useEffect(() => {
@@ -507,15 +611,53 @@ export default function LivePortrait({
}
}, [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,
]);
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");
}
};
return (
<>
<Container type="panel" className="mt-3">
<Container type="panel" className="mt-3 mt-lg-5">
<Panel padding={true}>
<h1 className="fw-bold fs-1 mb-0">
<FontAwesomeIcon icon={faImageUser} className="me-3 fs-2" />
Live Portrait
</h1>
<p className="opacity-75 fw-medium mb-3 mb-lg-0">
<p className="opacity-75 fw-medium" style={{ marginBottom: "3rem" }}>
Use AI to transfer facial expressions, audio, and vocals from one
face video to an image or video.
</p>
@@ -523,7 +665,7 @@ export default function LivePortrait({
<div className="row gx-0 gy-4">
<div
className="col-12 col-lg-3 d-flex gap-3 flex-column"
style={{ paddingTop: "4.2%" }}
// style={{ paddingTop: "4.2%" }}
>
<ThumbnailMediaPicker
mediaTokens={sourceTokens}
@@ -548,7 +690,7 @@ export default function LivePortrait({
<div
className="col-12 col-lg-3 d-flex gap-3 flex-column"
style={{ paddingTop: "4.2%" }}
// style={{ paddingTop: "4.2%" }}
>
<ThumbnailMediaPicker
mediaTokens={motionTokens}
@@ -602,10 +744,12 @@ export default function LivePortrait({
<div className="d-flex flex-column gap-4">
<div className="d-flex gap-2">
<Button
icon={faSparkles}
icon={isUserContent ? faSparkles : undefined}
label={
isUserContent
? "Animate"
? generatedVideoSrc
? "Re-animate"
: "Animate"
: "Upload your media to generate"
}
onClick={enqueueClick}
@@ -620,6 +764,7 @@ export default function LivePortrait({
square={true}
icon={faArrowDownToLine}
variant="action"
onClick={handleDownloadClick}
/>
</div>
</Tippy>
@@ -656,12 +801,27 @@ export default function LivePortrait({
</div>
</div>
</div>
<div className="mt-5 pt-5 order-3">
<div className="mt-5 pt-3 order-3">
<Label label="Latest Outputs" />
<div>
<SessionLpInferenceResultsList
sessionSubscriptionsWrapper={sessionSubscriptionsWrapper}
onJobTokens={handleJobTokens}
addSourceToken={(newToken: string) =>
setSourceTokens(prevTokens =>
prevTokens.includes(newToken)
? prevTokens
: [...prevTokens, newToken]
)
}
addMotionToken={(newToken: string) =>
setMotionTokens(prevTokens =>
prevTokens.includes(newToken)
? prevTokens
: [...prevTokens, newToken]
)
}
onJobClick={handleJobClick}
/>
</div>
</div>
@@ -1,24 +1,46 @@
import React, { useEffect, useMemo, useRef } from "react";
import { InferenceJob } from "@storyteller/components/src/jobs/InferenceJob";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
FrontendInferenceJobType,
InferenceJob,
} from "@storyteller/components/src/jobs/InferenceJob";
import { useInferenceJobs } from "hooks";
import { Panel } from "components/common";
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 { faClipboardList } from "@fortawesome/pro-solid-svg-icons";
import { faArrowTurnUp } from "@fortawesome/pro-solid-svg-icons";
interface SessionLpInferenceResultsListProps {
sessionSubscriptionsWrapper: any;
onJobTokens: (maybeResultToken: string, jobToken: string) => void;
onJobTokens: (
maybeResultToken: string,
jobToken: string,
createdAt: Date,
maybeLivePortraitDetails?: LivePortraitDetails
) => void;
addSourceToken: (token: string) => void;
addMotionToken: (token: string) => void;
onJobClick: (job: InferenceJob) => void;
}
export default function SessionLpInferenceResultsList({
sessionSubscriptionsWrapper,
onJobTokens,
addMotionToken,
addSourceToken,
onJobClick,
}: SessionLpInferenceResultsListProps) {
const { inferenceJobsByCategory } = useInferenceJobs();
const hasInitialized = useRef(false);
const livePortraitJobs = useMemo(() => {
return inferenceJobsByCategory.get(0) || [];
return (
inferenceJobsByCategory.get(FrontendInferenceJobType.LivePortrait) || []
);
}, [inferenceJobsByCategory]);
const lastProcessedJobToken = useRef<string | null>(null);
@@ -32,46 +54,142 @@ export default function SessionLpInferenceResultsList({
job.maybeResultToken &&
job.jobToken !== lastProcessedJobToken.current
) {
lastProcessedJobToken.current = job.jobToken;
onJobTokens(job.maybeResultToken, job.jobToken);
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]);
}, [livePortraitJobs, onJobTokens, addSourceToken, addMotionToken]);
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 || "";
console.log(`Fetched media for token ${token}: ${publicBucketPath}`);
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>
{livePortraitJobs.map((job: InferenceJob, key: number) => (
<div
key={key}
onClick={() => {
if (job.maybeResultToken) {
onJobTokens(job.maybeResultToken, job.jobToken);
}
}}
>
{job.maybeResultToken}
{job.jobToken}
</div>
))}
<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
? new BucketConfig().getGcsUrl(
mediaSrc[
job.maybeLivePortraitDetails
.source_media_file_token
] || ""
)
: ""
}
alt=""
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 mt-1">Waiting...</div>
)}
</div>
</div>
<FontAwesomeIcon
icon={faArrowTurnUp}
className="pe-2 fs-5 opacity-75"
/>
</div>
</div>
))}
</div>
) : (
<div className="d-flex flex-column p-4 gap-3 text-center align-items-center justify-content-center">
<FontAwesomeIcon icon={faClipboardList} className="display-6 mb-2" />
<div>
<h4 className="fw-semibold mb-1">title</h4>
<p className="opacity-75 mb-2">subtitle</p>
</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 live portrait generations will appear here.
</span>
</div>
)}
</div>
);
return (
<Panel className="fy-session-lp-inference-jobs-list rounded" padding={true}>
{jobContent}
</Panel>
);
return <>{jobContent}</>;
}
@@ -19,15 +19,30 @@ const ThumbnailItem: React.FC<ThumbnailItemProps> = ({
const [isThumbReady, setIsThumbReady] = useState(false);
useEffect(() => {
let isMounted = true;
const checkImage = () => {
const img = new Image();
img.src = poster + "-thumb.jpg";
img.onload = () => setIsThumbReady(true);
img.onerror = () => setTimeout(checkImage, 1000); // Retry after 1 second if the image is not available
img.onload = () => {
if (isMounted) {
setIsThumbReady(true);
}
};
img.onerror = () => {
if (isMounted && !isThumbReady) {
setTimeout(checkImage, 1000); // Retry after 1 second if the image is not available
}
};
};
checkImage();
}, [poster]);
if (poster && mediaType === "video") {
checkImage();
}
return () => {
isMounted = false;
};
}, [poster, mediaType, isThumbReady]);
return (
<div className="col-3" key={index}>
@@ -133,9 +133,10 @@ const ThumbnailMediaPicker: React.FC<ThumbnailMediaPickerProps> = React.memo(
[isCropping]
);
const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => {
console.log(croppedArea, croppedAreaPixels);
}, []);
const onCropComplete = useCallback(
(_croppedArea, _croppedAreaPixels) => {},
[]
);
const handleNextPage = () => {
setCurrentPage(prevPage =>
@@ -161,7 +162,9 @@ const ThumbnailMediaPicker: React.FC<ThumbnailMediaPickerProps> = React.memo(
<Badge label={badgeLabel} color="ultramarine" overlay={true} />
) : (
<Badge
label={`${isMobile ? "Pinch" : "Scroll"} and drag to crop`}
label={`${
isMobile ? "Pinch" : "Scroll zoom"
} and drag to crop`}
color="gray"
overlay={true}
/>
@@ -95,6 +95,7 @@ export default function MediaPageSwitch() {
pageTitle = mediaFile?.maybe_title || "Audio File";
break;
case MediaFileType.Video:
case MediaFileType.Mp4:
pageTitle = mediaFile?.maybe_title || "Video File";
break;
case MediaFileType.Image:
@@ -201,6 +202,7 @@ export default function MediaPageSwitch() {
const MediaSwitch = () => {
switch (mediaFile?.media_type) {
case MediaFileType.Video:
case MediaFileType.Mp4:
return <VideoMediaPage {...subViewProps} />;
default:
return <MediaPage {...subViewProps} />;
@@ -19,7 +19,7 @@ export default function VideosTab({ username }: { username: string }) {
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [showMasonryGrid, setShowMasonryGrid] = useState(true);
const [mediaType, mediaTypeSet] = useState(
urlQueries.get("filter_media_type") || "video"
urlQueries.get("filter_media_type") || "video,mp4"
);
const [list, listSet] = useState<MediaFile[]>([]);
const media = useListContent({