Merge pull request #261 from storytold/olivicmic-fy-lipsync-revamp

Olivicmic fy lipsync revamp
This commit is contained in:
Brandon Thomas authored and GitHub committed 2023-08-10 16:25:10 -04:00
commit 8cca99bc2c
35 files changed
+623

No files matched your search

@@ -0,0 +1 @@
.fy-audio-uploader > div { margin-bottom: 1rem }
@@ -0,0 +1,37 @@
import React from "react";
import { InputVcAudioPlayer } from "v2/view/_common/InputVcAudioPlayer";
import { FileActions, FileDetails, FileLabel, FileWrapper } from 'components/common';
import { faFileAudio } from "@fortawesome/pro-solid-svg-icons";
import './AudioInput.scss'
const fileTypes = ["MP3", "WAV", "FLAC", "OGG"];
interface Props {
blob?: string;
clear?: (file?: any) => void;
file?: any;
hideActions?: boolean;
hideClearDetails?: boolean;
inputProps?: any;
success?: boolean;
submit?: () => void;
working?: boolean;
[x:string]: any;
}
const n = () => {};
export default function AudioInput({ blob = "", clear = n, file, hideActions, hideClearDetails, inputProps, success = false, submit = n, working = false, ...rest }: Props) {
return <div {...{ className: "fy-audio-uploader" }}>
<FileWrapper {...{ fileTypes, ...inputProps, panelClass: "p-3", ...rest }}>
{ file ? <FileDetails {...{ clear, hideClearDetails, icon: faFileAudio, file }}/> : <FileLabel {...{ fileTypes }}/> }
</FileWrapper>
{ file && <>
<div {...{ className: "panel panel-inner rounded p-3" }}>
<InputVcAudioPlayer {...{ filename: blob as string }}/>
</div>
{ !hideActions && <FileActions {...{ clear, success, submit, working }}/> }
</> }
</div>;
};
@@ -0,0 +1 @@
export { default } from './AudioInput';
@@ -0,0 +1,28 @@
import React from 'react';
import { FontAwesomeIcon as Icon } from "@fortawesome/react-fontawesome";
import { faCheck, faFileArrowUp, faTrash } from "@fortawesome/pro-solid-svg-icons";
import LoadingIcon from "./LoadingIcon";
interface Props {
clear?: (x?: any) => void;
upload?: (x?: any) => void;
successful?: boolean;
working?: boolean;
}
const n = () => {};
export default function FileActions({ clear = n, upload = n, successful, working }: Props) {
const uploadBtnClass = successful ? "btn btn-uploaded w-100 disabled" : "btn btn-primary w-100";
return <div className="d-flex gap-3">
<button {...{ className: uploadBtnClass, disabled: working || successful, onClick: () => upload(), type: "submit", }}>
<Icon {...{ className: "me-2", icon: successful ? faCheck : faFileArrowUp, }}/>
{ working && <LoadingIcon /> }
</button>
<button {...{ className: "btn btn-destructive w-100", onClick: clear }}>
<Icon icon={faTrash} className="me-2" />
Clear
</button>
</div>;
};
@@ -0,0 +1,12 @@
import React from "react";
export default function LoadingIcon() {
return <>
<span
className="spinner-border spinner-border-sm ms-3"
role="status"
aria-hidden="true"
></span>
<span className="visually-hidden">Loading...</span>
</>;
};
@@ -0,0 +1 @@
export { default } from './FileActions';
@@ -0,0 +1,10 @@
.upload-details {
width: 100%;
.upload-details-clear {
height: 36px;
width: 36px;
margin: auto 0 auto auto;
padding: 10px;
}
}
@@ -0,0 +1,36 @@
import React from 'react';
import { FontAwesomeIcon as Icon } from "@fortawesome/react-fontawesome";
import { faTrash } from "@fortawesome/pro-solid-svg-icons";
import "./FileDetails.scss";
interface Props {
clear?: (file?: any) => void;
file?: any;
hideClearDetails?: boolean;
icon?: any;
}
export default function FileDetails({ clear = () => {}, file, hideClearDetails, icon }: Props) {
const fileSize =
file && file.size >= 1024 * 1024
? (file.size / 1024 / 1024).toFixed(2) + " MB"
: file
? `${Math.floor(file.size / 1024)} KB`
: null;
return <div {...{ className: "fy-uploader-layout upload-details" }}>
<Icon {...{ className: "fy-uploader-layout-icon", icon }}/>
<div>
<div {...{ className: "filename" }}>
{ file.name.slice(0, file.name.lastIndexOf(".")) }
</div>
<span className="opacity-50">
{`${ file.name.split(".").pop().toUpperCase() } file size: ${ fileSize }`}
</span>
<u className="fw-medium opacity-100 ms-1">Change file</u>
</div>
{ !hideClearDetails && <button {...{ className: "upload-details-clear btn btn-destructive align-items-center justify-content-center", onClick: e => { e.preventDefault(); clear() } }}>
<Icon {...{ icon: faTrash }}/>
</button> }
</div>;
};
@@ -0,0 +1 @@
export { default } from './FileDetails';
Whitespace-only changes.
@@ -0,0 +1,20 @@
import React from "react";
import { FontAwesomeIcon as Icon } from "@fortawesome/react-fontawesome";
import { faFileArrowUp } from "@fortawesome/pro-solid-svg-icons";
import "./FileLabel.scss";
interface Props {
fileTypes?: string[];
}
export default function FileLabel({ fileTypes = [] }: Props) {
return <div {...{ className: "fy-uploader-layout fy-upload-label" }}>
<Icon {...{ className: "fy-uploader-layout-icon", icon: faFileArrowUp }}/>
<div className="pb-0">
<u className="fw-medium">Upload a file</u> or drop it here...
<p className="opacity-50">
{ fileTypes.join(", ").toString() } supported
</p>
</div>
</div>;
};
@@ -0,0 +1 @@
export { default } from './FileLabel';
@@ -0,0 +1,33 @@
@import "scss/colours.scss";
.fy-uploader {
[type="file"] {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
overflow: hidden;
padding: 0;
position: absolute !important;
white-space: nowrap;
width: 1px;
+ label {
cursor: pointer;
border: dashed 2px rgba(255, 255, 255, 0.12) !important;
border-radius: 0.5rem;
transition: 0.1s all;
&:hover {
background-color: lighten($panel, 6%);
}
}
}
.fy-uploader-layout {
display: flex;
align-items: center;
.fy-uploader-layout-icon {
font-size: 2.625rem;
margin-right: 1rem;
}
}
}
@@ -0,0 +1,41 @@
import React from "react";
import { useId } from 'hooks'; // replace with react v18
import './FileWrapper.scss'
interface Props {
children?: JSX.Element|JSX.Element[];
containerClass?: string;
fileTypes?: string[];
inputRef: any;
onChange: (file?: any) => void;
panelClass?: string;
[x:string]: any;
};
export default function FileWrapper({ children, containerClass, fileTypes = [], inputRef, onChange, panelClass, ...rest }: Props) {
const id = 'file-input-' + useId();
const accept = fileTypes.map((type,i) => `.${ type.toLowerCase() }`).join(",");
const fileChange = (e: any) => {
e.preventDefault();
onChange({ target: { name: e.target?.name || "file-input", value: e.target.files[0] }});
};
const onDragDrop = (e: any) => { e.preventDefault(); e.stopPropagation(); };
const onDragEvent = (onOff: number) => (e: React.DragEvent<HTMLDivElement>): void => {
onDragDrop(e);
e.currentTarget.classList[onOff ? "add" : "remove" ]("upload-zone-drag");
};
const onDrop = (e: any): void => {
onDragDrop(e);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
onChange({ target: { name: e.target?.name || "file-input", value: e.dataTransfer.files[0] }});
}
};
return <div {...{ className: `fy-uploader${ containerClass ? " " + containerClass : "" }`, onDragLeave: onDragEvent(1), onDragOver: onDragEvent(0), onDrop }}>
<input { ...{ accept, onChange: fileChange, type: "file", id, ref: inputRef, ...rest }} />
<label {...{ className: `panel panel-inner d-flex align-items-center${ panelClass ? " " + panelClass : "" }`, htmlFor: id }} >
{ children }
</label>
</div>;
};
@@ -0,0 +1,12 @@
import React from "react";
export default function LoadingIcon() {
return <>
<span
className="spinner-border spinner-border-sm ms-3"
role="status"
aria-hidden="true"
></span>
<span className="visually-hidden">Loading...</span>
</>;
};
@@ -0,0 +1 @@
export { default } from './FileWrapper';
@@ -0,0 +1,39 @@
@import "scss/colours.scss";
.fy-image-uploader {
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
height: 100%;
width: 100%;
.file-preview {
height: 100%;
width: 100%;
border-radius: .5rem;
object-fit: cover;
}
.image-upload-label, .upload-details {
background-color: rgba($panel, .85);
position: absolute;
}
.image-upload-label {
border-radius: .5rem .5rem 0 0;
display: flex;
width: 100%;
top: 0;
padding: 1rem;
}
.upload-details {
top: 1rem;
left: 1rem;
width: calc(100% - 2rem);
}
.image-placeholder {
// background-color: red;
fill: $secondary;
}
}
@@ -0,0 +1,38 @@
import React from "react";
import { FileDetails, FileWrapper, FileLabel } from "components/common";
import "./ImageInput.scss";
const fileTypes = ["JPG", "GIF", "PNG"];
interface Props {
blob?: string;
clear?: (file?: any) => void;
file?: any;
hideActions?: boolean;
hideClearDetails?: boolean;
inputProps?: any;
success?: boolean;
submit?: () => void;
working?: boolean;
[x:string]: any;
}
const n = () => {};
export default function ImageInput({ blob = "", clear = n, file, hideActions, hideClearDetails, inputProps, success = false, submit = n, working = false, ...rest }: Props) {
return <FileWrapper {...{ fileTypes, ...inputProps, ...rest }}>
<div {...{ className: "fy-image-uploader" }}>
{ file ? <>
<img {...{ alt: "file preview", className: "file-preview", src: blob }} />
<FileDetails {...{ file, hideClearDetails }}/>
</> : <>
<svg {...{ className: "image-placeholder", height: 400, viewBox: "0 0 300 300", width: 400 }}>
<path d="m152.42 226c41.8 0 69.49-43.57 73.21-90.87 3.78-47.97-26.06-87.13-75.69-87.13-49.64 0-79.28 39.15-75.69 87.13 3.72 49.79 36.37 90.87 78.17 90.87zm-29.19-50.93c1.62-1.51 4.15-1.42 5.65.2 4.1 4.41 15.31 6.97 21.88 6.97s17.77-2.56 21.88-6.97c1.5-1.62 4.04-1.71 5.65-.2 1.62 1.5 1.92 3.95.2 5.65-6.31 6.27-18.31 9.52-27.74 9.52s-22.16-3.54-27.74-9.52c-1.49-1.61-1.4-4.14.22-5.65zm115.25 112.93h-176.62c-37.67 0-38.24-17.41-27.56-26.36 21.49-18.01 57.18-23.64 114.81-23.64 60.01 0 96.24 4.84 117.41 24.06 10.09 9.16 7.67 25.94-28.04 25.94z"/>
</svg>
<div {...{ className: "image-upload-label" }}>
{ !file && <FileLabel {...{ fileTypes }} /> }
</div>
</> }
</div>
</FileWrapper>;
};
@@ -0,0 +1 @@
export { default } from './ImageInput';
@@ -0,0 +1,15 @@
import AudioInput from './AudioInput';
import ImageInput from './ImageInput';
import FileActions from './FileActions';
import FileDetails from './FileDetails';
import FileWrapper from './FileWrapper';
import FileLabel from './FileLabel';
export {
AudioInput,
ImageInput,
FileActions,
FileDetails,
FileWrapper,
FileLabel
};
@@ -0,0 +1,33 @@
import React from "react";
interface Props {
childProps?: any;
titleIcon?: JSX.Element;
titleComponent?: React.ComponentType;
title?: string;
subText: string;
showButtons: boolean;
actionButtons?: JSX.Element;
}
export default function PageHeader({ actionButtons, childProps, showButtons, subText, titleComponent: Title, title, titleIcon }: Props) {
return <div className="container-panel hero-section py-4">
<div className="panel">
<div className="p-3 py-4 p-md-4">
{ Title ? <Title {...childProps }/> : <h1 className="fw-bold text-center text-md-start">
{ titleIcon }
{ title }
</h1> }
<p className="text-center text-md-start pt-1">{ subText }</p>
{ showButtons && (
<div className="d-flex flex-column flex-md-row gap-3 justify-content-center justify-content-md-start mt-4">
{ actionButtons }
</div>
)}
</div>
</div>
</div>;
};
// export default PageHeader;
@@ -0,0 +1 @@
export { default } from './PageHeader';
@@ -0,0 +1,5 @@
import PageHeader from './PageHeader';
export {
PageHeader,
};
+5
View File
@@ -0,0 +1,5 @@
import useFile from './useFile';
import useId from './useId';
import useIdempotency from './useIdempotency';
export { useFile, useId, useIdempotency };
@@ -0,0 +1 @@
export { default } from "./useFile";
@@ -0,0 +1,53 @@
import { useRef, useState } from 'react';
const n = (o: any) => o;
interface Props {
debug?: any;
onChange?: (file: any) => void;
onClear?: (x?: any) => void;
onSubmit?: (file: any) => boolean;
}
export default function useFile({ debug, onChange = n, onClear = n, onSubmit = n }: Props) {
const [file, fileSet] = useState<any>(undefined);
const [blob, blobSet] = useState<string>();
const [working, workingSet] = useState(false);
const [success, successSet] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const submit = async () => {
if (file === undefined) { return false; }
const onUploadResult = await onSubmit(file);
workingSet(true);
if (onUploadResult) { successSet(true); }
successSet(false);
};
const fileChange = (inputFile?: any) => {
onChange(file);
fileSet(inputFile || null);
blobSet(inputFile ? URL.createObjectURL(inputFile) : "");
successSet(false);
};
const inputChange = ({ target = {} }: { target: any }) => {
fileChange(target.value);
};
const clear = () => {
if (inputRef?.current?.value) inputRef.current.value = '';
fileChange();
onClear();
};
return {
blob,
clear,
file,
inputProps: {
onChange: inputChange,
inputRef
},
submit,
success,
working,
};
};
@@ -0,0 +1 @@
export { default } from './useId';
@@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
const S4 = () => (((1+Math.random())*0x10000)|0).toString(16).substring(1);
export default function useId() {
const [id,idSet] = useState<string>();
useEffect(() => {
if (!id) idSet(S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4());
},[id]);
return id;
};
// based on https://stackoverflow.com/questions/6860853/generate-random-string-for-div-id/6860916#6860916
@@ -0,0 +1 @@
export { default } from './useIdempotency';
@@ -0,0 +1,7 @@
import { useState } from 'react';
import { v4 as uuidv4 } from "uuid";
export default function useIdempotency() {
const [token, set] = useState(uuidv4());
return { reset: () => set(uuidv4()), set, token };
};
@@ -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 { LipsyncEditor } from "./pages/lipsync";
import { FooterNav } from "./nav/FooterNav";
import { TopNav } from "./nav/TopNav";
import { ProfileEditFc } from "./pages/profile/profile_edit/ProfileEditFc";
@@ -429,6 +430,10 @@ class PageContainer extends React.Component<Props, State> {
<CreateCategoryPage sessionWrapper={this.props.sessionWrapper} />
</Route>
<Route path="/dev-lipsync">
<LipsyncEditor />
</Route>
<Route path="/moderation/user/list">
<ModerationUserListFc
sessionWrapper={this.props.sessionWrapper}
@@ -0,0 +1,88 @@
.lipsync-editor {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-gap: 1rem;
.grid-heading, .grid-square {
&:nth-child(odd) {
padding-left: 1rem;
}
&:nth-child(even) {
padding-right: 1rem;
}
}
.grid-heading {
display: flex;
align-items: center;
// background-color: blue;
// padding-left: 1rem;
h5 {
margin: 1rem 0 0;
// background: red;
line-height: 1em;
}
}
.grid-square {
aspect-ratio: 1;
padding-bottom: 1rem;
// &:nth-child(odd) {
// padding-left: 1rem;
// }
// &:nth-child(even) {
// padding-right: 1rem;
// }
}
// div.grid-square { aspect-ratio: 1 }
.fy-image-uploader { height: 100%; }
.lipsync-audio {
label { height: 100% }
}
}
.progress-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
.progress-heading {
display: inline-block;
margin: auto 1rem auto 0;
}
.async-progress-tracker {
display: inline-flex;
justify-content: space-around;
flex-grow: 1;
margin-right: 1rem;
padding-left: 0;
li {
display: flex;
align-items: center;
font-size: 1.25rem;
font-weight: 500;
}
svg {
border: 2px solid #fff;
border-radius: 50%;
margin-right: .5rem;
height: 2rem;
width: 2rem;
stroke: white;
}
}
button {
margin: 0 0 0 auto;
background-color: #ccccff;
// flex-grow: 1;
width: fit-content;
}
}
@@ -0,0 +1,76 @@
import React from "react";
import { useFile } from "hooks";
import { AudioInput, ImageInput } from "components/common";
import { PageHeader } from "components/layout";
import './LipsyncEditor.scss';
// interface Props {
// audioFile?: any;
// imageFile?: any;
// }
const ProgressCheck = ({ disabled = false }: {disabled?: boolean}) => <svg>
{ !disabled && <polyline {...{
fill: 'none',
points: '7 16 12 20 20 10',
strokeLinecap: 'round',
strokeLinejoin: 'round',
strokeWidth: '4',
}}/> }
</svg>;
const Title = ({ ...rest }) => {
const { audioFile, imageFile } = rest;
return <div {...{ className: 'progress-header' }}>
<h1 {...{ className: "fw-bold text-center text-md-start progress-heading" }}>
Generate a lipsync
</h1>
<ul {...{ className: 'async-progress-tracker' }}>
<li>
<ProgressCheck {...{ disabled: imageFile }}/>
Image
</li>
<li>
<ProgressCheck {...{ disabled: audioFile }}/>
Audio
</li>
</ul>
<button className="btn btn-primary">
Generate
</button>
</div>
};
export default function LipsyncEditor({ ...rest }) {
const audioProps = useFile({ debug: 'audio useFile' });
const imageProps = useFile({});
const subText = "Select an image with a clear face, or one of our existing templates, then choose either text to speech or uploaded audio(eg. music). Then you can generate a beautifully lipsynced video.";
const headerProps = {
childProps: { audioFile: !audioProps.file, imageFile: !imageProps.file },
titleComponent: Title,
subText,
showButtons: false
};
return <div>
<PageHeader { ...headerProps }/>
<div {...{ className: "container" }}>
<div {...{ className: "lipsync-editor panel" }}>
<div {...{ className: "grid-heading" }}>
<h5>Image</h5>
</div>
<div {...{ className: "grid-heading" }}>
<h5>Audio</h5>
</div>
<div {...{ className: "grid-square lipsync-audio" }}>
<ImageInput {...imageProps}/>
</div>
<div {...{ className: "grid-square" }}>
<AudioInput {...{ ...audioProps, hideActions: true } }/>
</div>
</div>
</div>
</div>;
};
@@ -0,0 +1 @@
export { default } from './LipsyncEditor';
@@ -0,0 +1,3 @@
import LipsyncEditor from './LipsyncEditor';
export { LipsyncEditor };