mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-07 02:21:23 +00:00
face animation editor with audio > image > animation > to job workflow
This commit is contained in:
1 parent
78dadbf026
commit
05fc58da6c
9 files changed
+184
-42
No files matched your search
@@ -402,6 +402,14 @@ class ApiConfig {
|
||||
return `${this.getApiOrigin()}/v1/media_uploads/upload_audio`;
|
||||
}
|
||||
|
||||
uploadImage(): string {
|
||||
return `${this.getApiOrigin()}/v1/media_uploads/upload_image`;
|
||||
}
|
||||
|
||||
uploadVideo(): string {
|
||||
return `${this.getApiOrigin()}/v1/media_uploads/upload_video`;
|
||||
}
|
||||
|
||||
uploadMedia(): string {
|
||||
return `${this.getApiOrigin()}/v1/media_uploads/upload`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApiConfig } from "../ApiConfig";
|
||||
|
||||
export interface UploadRequest {
|
||||
uuid_idempotency_token: string,
|
||||
source?: string, // eg. "device", "file"
|
||||
type: string, // "audio", "image", "video"
|
||||
file: any,
|
||||
};
|
||||
|
||||
export interface UploadResponse {
|
||||
success: boolean,
|
||||
upload_token?: string,
|
||||
}
|
||||
|
||||
const { uploadAudio, uploadImage, uploadVideo } = new ApiConfig();
|
||||
|
||||
const audio = new ApiConfig().uploadAudio();
|
||||
const image = new ApiConfig().uploadImage();
|
||||
const video = new ApiConfig().uploadVideo();
|
||||
|
||||
const endpoints = { audio, image, video };
|
||||
|
||||
export async function Upload(request: UploadRequest) : Promise<UploadResponse>
|
||||
{
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('uuid_idempotency_token', request.uuid_idempotency_token);
|
||||
formData.append('file', request.file);
|
||||
|
||||
if (request.source !== undefined) {
|
||||
formData.append('source', request.source);
|
||||
}
|
||||
|
||||
return fetch(endpoints[request.type as keyof typeof endpoints], {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (res && 'success' in res) {
|
||||
return res;
|
||||
} else {
|
||||
return { success : false };
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
return { success : false };
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { JobState, jobStateFromString } from "./JobStates";
|
||||
|
||||
// Type of inference job (specified by the frontend, not backend)
|
||||
export enum FrontendInferenceJobType {
|
||||
FaceAnimation,
|
||||
TextToSpeech,
|
||||
VoiceConversion,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { a, useSpring, useTransition } from '@react-spring/web';
|
||||
import { a, useTransition } from '@react-spring/web';
|
||||
import { InputVcAudioPlayer } from "v2/view/_common/InputVcAudioPlayer";
|
||||
import { FileActions, FileDetails, FileLabel, FileWrapper } from 'components/common';
|
||||
import { faFileAudio } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LoginPage } from "./pages/login/LoginPage";
|
||||
import { ModerationFc } from "./pages/moderation/moderation_main/ModerationFc";
|
||||
import { ModerationIpBanListFc } from "./pages/moderation/moderation_ip_ban_list/ModerationIpBanListFc";
|
||||
import { ModerationViewIpBanFc } from "./pages/moderation/moderation_view_ip_ban/ModerationViewIpBanFc";
|
||||
import { Media } from "./pages/media";
|
||||
import { LipsyncEditor } from "./pages/lipsync";
|
||||
import { FooterNav } from "./nav/FooterNav";
|
||||
import { TopNav } from "./nav/TopNav";
|
||||
@@ -431,7 +432,11 @@ class PageContainer extends React.Component<Props, State> {
|
||||
</Route>
|
||||
|
||||
<Route path="/dev-lipsync">
|
||||
<LipsyncEditor />
|
||||
<LipsyncEditor {...{ enqueueInferenceJob: this.props.enqueueInferenceJob }} />
|
||||
</Route>
|
||||
|
||||
<Route path="/media/:token">
|
||||
<Media/>
|
||||
</Route>
|
||||
|
||||
<Route path="/moderation/user/list">
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.lipsync-panel { position: relative; height: fit-content; }
|
||||
.lipsync-panel { position: relative; height: fit-content; overflow: hidden; }
|
||||
|
||||
// .lipsync-editor, .lipsync-working, .lipsync-success {
|
||||
// position: absolute;
|
||||
@@ -97,8 +97,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// .lipsync-working-notice {
|
||||
// background-color: red;
|
||||
// }
|
||||
|
||||
// .lipsync-working {
|
||||
// background-color: green;
|
||||
// }
|
||||
|
||||
.lipsync-working-notice {
|
||||
flex-direction: column;
|
||||
margin: 5rem;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import React, { useState } from "react";
|
||||
import { animated, useSpring, useTransition } from '@react-spring/web';
|
||||
import { useFile } from "hooks";
|
||||
import { useFile, useIdempotency } from "hooks";
|
||||
import { AudioInput, ImageInput, Spinner } from "components/common";
|
||||
import DynamicButton from './DynamicButton';
|
||||
import { Upload } from "@storyteller/components/src/api/upload/upload";
|
||||
import { FrontendInferenceJobType } from "@storyteller/components/src/jobs/InferenceJob";
|
||||
import './LipsyncEditor.scss';
|
||||
|
||||
interface LipSyncProps { audioProps: any, imageProps: any, toggle: any, style: any };
|
||||
interface LipSyncProps { audioProps: any, children?: any, imageProps: any, toggle: any, style: any };
|
||||
interface EditorProps { enqueueInferenceJob: (
|
||||
jobToken: string,
|
||||
frontendInferenceJobType: FrontendInferenceJobType
|
||||
) => void };
|
||||
|
||||
const softSpring = { config: { mass: 1, tension: 80, friction: 10 } }
|
||||
|
||||
@@ -13,10 +19,7 @@ const SuccessPage = ({ audioProps, imageProps, style }: LipSyncProps ) => <anim
|
||||
<h3 className=" fw-bold text-center text-lg-start">
|
||||
Result
|
||||
</h3>
|
||||
<video width="100%" height="auto" controls={true} className="rounded">
|
||||
<source src="https://pics.vics.pics/3027969248.mp4" />
|
||||
Your device doesn't support video.
|
||||
</video>
|
||||
Your animation is in progress.
|
||||
</animated.div>;
|
||||
|
||||
const InputPage = ({ audioProps, imageProps, toggle, style }: LipSyncProps ) => {
|
||||
@@ -38,13 +41,17 @@ const InputPage = ({ audioProps, imageProps, toggle, style }: LipSyncProps ) =>
|
||||
</div>
|
||||
</animated.div>};
|
||||
|
||||
const WorkingPage = ({ audioProps, imageProps, style }: LipSyncProps ) => <animated.div {...{ className: "lipsync-working", style }}>
|
||||
const Working = ({ audioProps, children = "", imageProps, style }: LipSyncProps ) => <animated.div {...{ className: "lipsync-working", style }}>
|
||||
<div {...{ className: "lipsync-working-notice" }}>
|
||||
<h2>Working ...</h2>
|
||||
<h2>{ children }</h2>
|
||||
<Spinner />
|
||||
</div>
|
||||
</animated.div>;
|
||||
|
||||
const AudioWorking = (props: any) => <Working { ...props }>Uploading Audio ...</Working>;
|
||||
const ImageWorking = (props: any) => <Working { ...props }>Uploading Image ...</Working>;
|
||||
const AniWorking = (props: any) => <Working { ...props }>Starting animation ...</Working>;
|
||||
|
||||
const ProgressLi = ({ children, disabled = false }: { children?: any, disabled?: boolean }) => {
|
||||
const style = useSpring({
|
||||
...softSpring,
|
||||
@@ -54,11 +61,11 @@ const ProgressLi = ({ children, disabled = false }: { children?: any, disabled?:
|
||||
<svg>
|
||||
<circle {...{ cx: 16, cy: 16, r: 15, strokeWidth: "2", }}/>
|
||||
{ <polyline {...{
|
||||
fill: 'none',
|
||||
points: '9.5 18 14.5 22 22.5 12',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
strokeWidth: '4',
|
||||
fill: "none",
|
||||
points: "9.5 18 14.5 22 22.5 12",
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
strokeWidth: "4",
|
||||
}}/> }
|
||||
</svg>
|
||||
{ children }
|
||||
@@ -72,7 +79,7 @@ const Title = ({ ...rest }) => {
|
||||
const incomplete = noAudio || noImg;
|
||||
const working = imageProps.working && audioProps.working;
|
||||
|
||||
const slides = ["Generate",<Spinner />,"Make another"];
|
||||
const slides = ["Generate",<Spinner />,<Spinner />,<Spinner />,"Make another"];
|
||||
|
||||
const onClick = () => {
|
||||
if (imageProps.success && audioProps.success) {
|
||||
@@ -80,7 +87,7 @@ const Title = ({ ...rest }) => {
|
||||
} else if (!incomplete && !working) submit();
|
||||
};
|
||||
|
||||
return <div {...{ className: 'progress-header' }}>
|
||||
return <div {...{ className: "progress-header" }}>
|
||||
<h1 {...{ className: "fw-bold text-center text-md-start progress-heading" }}>
|
||||
Lip Service
|
||||
</h1>
|
||||
@@ -93,38 +100,82 @@ const Title = ({ ...rest }) => {
|
||||
</ProgressLi>
|
||||
</ul>
|
||||
<DynamicButton {...{ disabled: incomplete || working, onClick, slides, index }}/>
|
||||
<p {...{ className: 'progress-description' }}>
|
||||
<p {...{ className: "progress-description" }}>
|
||||
Select and image with a clear face, and an audio sample, and generate a lipsynced video.
|
||||
</p>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default function LipsyncEditor({ ...rest }) {
|
||||
const fakeAPI = async (anything: any) => { // this just simulates the unifinished apis
|
||||
await new Promise(resolve => {
|
||||
setTimeout(() => resolve({ success: true }), 3000);
|
||||
});
|
||||
return { success: true, inference_job_token: "fake", upload_token: "also fake", };
|
||||
};
|
||||
|
||||
export default function LipsyncEditor({ enqueueInferenceJob, ...rest }: EditorProps) {
|
||||
|
||||
// the ready states are set by functions which run after the upload input animation is completed, which then illuminates the respective checkmark in a staggered way to draw attention to the workflow, and reduces concurrent animations
|
||||
|
||||
const [imageReady,imageReadySet] = useState<boolean>(false);
|
||||
const [audioReady,audioReadySet] = useState<boolean>(false);
|
||||
const readyMedia = (m:number) => (t:boolean) => [imageReadySet,audioReadySet][m](t);
|
||||
const onSubmit = async () => {
|
||||
await new Promise(resolve => {
|
||||
setTimeout(function () {
|
||||
resolve();
|
||||
}, 6000);
|
||||
|
||||
const idempotency = useIdempotency();
|
||||
const audioProps = useFile({}); // contains upload inout state and controls, see docs
|
||||
const imageProps = useFile({}); // contains upload inout state and controls, see docs
|
||||
const [index,indexSet] = useState<number>(0); // index = slideshow slide position
|
||||
const [audioUploadToken,audioUploadTokenSet] = useState<string>();
|
||||
const [imageUploadToken,imageUploadTokenSet] = useState<string>();
|
||||
|
||||
const makeRequest = (mode: number) => ({
|
||||
uuid_idempotency_token: idempotency.token,
|
||||
file: mode ? imageProps.file : audioProps.file,
|
||||
source: "file",
|
||||
type: mode ? "image" : "audio"
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (!audioProps.file) return false;
|
||||
|
||||
indexSet(1); // set audio working page
|
||||
|
||||
Upload(makeRequest(0)) // start audio (0) upload
|
||||
.then(res => {
|
||||
if ("success" in res) {
|
||||
audioUploadTokenSet(res.upload_token); // audio token stored
|
||||
idempotency.reset(); // reset token for new request, I don't know if this needs to happen
|
||||
indexSet(2); // set image working page
|
||||
}
|
||||
return fakeAPI(makeRequest(1)); // start image (1) upload, replace with Upload(imageRequest)
|
||||
})
|
||||
.then(res => {
|
||||
if ("success" in res) {
|
||||
imageUploadTokenSet(res.upload_token); // image token stored
|
||||
idempotency.reset();
|
||||
indexSet(3); // set face animator API working page
|
||||
}
|
||||
return fakeAPI({
|
||||
source_audio_media_upload_token: audioUploadToken,
|
||||
source_image_media_upload_token: imageUploadToken,
|
||||
uuid_idempotency_token: idempotency.token
|
||||
});
|
||||
})
|
||||
.then(res => {
|
||||
if ("success" in res) {
|
||||
// this breaks because I don't get a real token
|
||||
// enqueueInferenceJob(res.inference_job_token,FrontendInferenceJobType.FaceAnimation);
|
||||
idempotency.reset();
|
||||
indexSet(4); // set face animator API working page
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
return { success : false };
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const audioProps = useFile({ debug: 'audio useFile', onSubmit });
|
||||
const imageProps = useFile({ onSubmit });
|
||||
const index = audioProps.status === imageProps.status ? audioProps.status : -1;
|
||||
const headerProps = {
|
||||
audioProps,
|
||||
audioReady,
|
||||
imageProps,
|
||||
imageReady,
|
||||
index,
|
||||
submit: () => {
|
||||
audioProps.submit(); imageProps.submit();
|
||||
}
|
||||
};
|
||||
|
||||
const headerProps = { audioProps, audioReady, imageProps, imageReady, index, submit };
|
||||
|
||||
const transitions = useTransition(index, {
|
||||
...softSpring,
|
||||
from: { opacity: 0, position: "absolute" },
|
||||
@@ -137,13 +188,13 @@ export default function LipsyncEditor({ ...rest }) {
|
||||
<div {...{ className: "panel lipsync-panel" }}>
|
||||
<Title { ...headerProps }/>
|
||||
{ transitions((style, i) => {
|
||||
const Page = [InputPage,WorkingPage,SuccessPage][i];
|
||||
return i > -1 ? <Page {...{
|
||||
const Page = [InputPage,AudioWorking,ImageWorking,AniWorking,SuccessPage][i];
|
||||
return Page ? <Page {...{
|
||||
audioProps,
|
||||
imageProps,
|
||||
toggle: { audio: readyMedia(1), image: readyMedia(0) },
|
||||
style
|
||||
}}/> : null
|
||||
}}/> : <></>
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import media from './media';
|
||||
|
||||
export { media as Media };
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
value?: any;
|
||||
}
|
||||
|
||||
export default function media({ value }: Props) {
|
||||
return <div>
|
||||
<h1>Media</h1>
|
||||
</div>;
|
||||
};
|
||||
Reference in new issue
Block a user