mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 02:00:46 +00:00
F5-TTS product page (#720)
* f5-tts product page * footer fix * ai tools section on f5-tts update * final updates
This commit is contained in:
1 parent
2a0c3b43a3
commit
0aba1957e8
17 files changed
+1208
-34
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import MakeRequest from "../MakeRequest";
|
||||
|
||||
export interface GenerateF5TtsAudioRequest {
|
||||
uuid_idempotency_token: string;
|
||||
source_media_file_token: string;
|
||||
inference_text: string;
|
||||
creator_set_visibility: string;
|
||||
}
|
||||
|
||||
export interface GenerateF5TtsAudioResponse {
|
||||
success: boolean;
|
||||
inference_job_token: string;
|
||||
}
|
||||
|
||||
export const GenerateF5TtsAudio = MakeRequest<
|
||||
string,
|
||||
GenerateF5TtsAudioRequest,
|
||||
GenerateF5TtsAudioResponse,
|
||||
{}
|
||||
>({
|
||||
method: "POST",
|
||||
routingFunction: () => "/v1/tts/f5_inference",
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export enum FrontendInferenceJobType {
|
||||
VideoWorkflow, // workflow ?
|
||||
VideoStyleTransfer, // video_filter
|
||||
LivePortrait, // live_portrait
|
||||
F5Tts, // f5_tts
|
||||
}
|
||||
|
||||
export enum AllInferenceJobs {
|
||||
|
||||
@@ -207,6 +207,10 @@ export default function AITools() {
|
||||
items = [items[0], items[1], items[2], items[3], items[4], items[5]];
|
||||
}
|
||||
|
||||
if (currentPath.includes("/f5-tts")) {
|
||||
items = [items[0], items[1], items[2], items[3], items[4], items[5]];
|
||||
}
|
||||
|
||||
if (currentPath.includes("/style-video")) {
|
||||
items = [items[1], items[2], items[8]];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import useCameraState, { CameraState } from "./useCameraState";
|
||||
import useChanger from "./useChanger";
|
||||
import useCoverImgUpload from "./useCoverImgUpload";
|
||||
import useDebounce from "./useDebounce";
|
||||
import useF5Store from "./useF5Store";
|
||||
import useFile from "./useFile";
|
||||
import useHover, { HoverValues } from "./useHover";
|
||||
import useId from "./useId";
|
||||
@@ -50,6 +51,7 @@ export {
|
||||
useChanger,
|
||||
useCoverImgUpload,
|
||||
useDebounce,
|
||||
useF5Store,
|
||||
useFile,
|
||||
useHover,
|
||||
useId,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./useF5Store";
|
||||
@@ -0,0 +1,45 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface F5StoreState {
|
||||
mediaUploadToken: string | undefined;
|
||||
setMediaUploadToken: (token: string | undefined) => void;
|
||||
hasUploadedFile: boolean;
|
||||
setHasUploadedFile: (value: boolean) => void;
|
||||
hasRecordedFile: boolean;
|
||||
setHasRecordedFile: (value: boolean) => void;
|
||||
recordingBlobStore: Blob | undefined;
|
||||
setRecordingBlobStore: (blob: Blob | undefined) => void;
|
||||
isUploadDisabled: boolean;
|
||||
setIsUploadDisabled: (value: boolean) => void;
|
||||
file: any;
|
||||
setFile: (file: any) => void;
|
||||
audioLink: string | undefined;
|
||||
setAudioLink: (link: string | undefined) => void;
|
||||
formIsCleared: boolean;
|
||||
setFormIsCleared: (cleared: boolean) => void;
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
}
|
||||
|
||||
const useF5Store = create<F5StoreState>(set => ({
|
||||
mediaUploadToken: undefined,
|
||||
setMediaUploadToken: token => set({ mediaUploadToken: token }),
|
||||
hasUploadedFile: false,
|
||||
setHasUploadedFile: value => set({ hasUploadedFile: value }),
|
||||
hasRecordedFile: false,
|
||||
setHasRecordedFile: value => set({ hasRecordedFile: value }),
|
||||
recordingBlobStore: undefined,
|
||||
setRecordingBlobStore: blob => set({ recordingBlobStore: blob }),
|
||||
isUploadDisabled: false,
|
||||
setIsUploadDisabled: value => set({ isUploadDisabled: value }),
|
||||
file: undefined,
|
||||
setFile: file => set({ file: file }),
|
||||
audioLink: undefined,
|
||||
setAudioLink: link => set({ audioLink: link }),
|
||||
formIsCleared: false,
|
||||
setFormIsCleared: cleared => set({ formIsCleared: cleared }),
|
||||
text: "",
|
||||
setText: text => set({ text }),
|
||||
}));
|
||||
|
||||
export default useF5Store;
|
||||
@@ -42,6 +42,8 @@ const JobCategoryToType = (jobCategory: string) => {
|
||||
return FrontendInferenceJobType.EngineComposition;
|
||||
case "live_portrait":
|
||||
return FrontendInferenceJobType.LivePortrait;
|
||||
case "f5_tts":
|
||||
return FrontendInferenceJobType.F5Tts;
|
||||
default:
|
||||
return FrontendInferenceJobType.Unknown;
|
||||
}
|
||||
|
||||
@@ -1450,6 +1450,14 @@ styles with important tags:
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
&.empty-zone-f5 {
|
||||
height: 120px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-zone-drag {
|
||||
@@ -1889,6 +1897,10 @@ textarea {
|
||||
height: 511px;
|
||||
}
|
||||
|
||||
.session-f5-section {
|
||||
height: 637px;
|
||||
}
|
||||
|
||||
.session-tts-section::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: "/f5-tts",
|
||||
component: lazy(() => import("./pages/voice_designer_v2/VoiceDesignerV2")),
|
||||
component: lazy(() => import("./pages/f5_tts/F5TTS")),
|
||||
},
|
||||
{
|
||||
path: "/style-video/:mediaToken?",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
|
||||
import { JobState } from "@storyteller/components/src/jobs/JobStates";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faClock, faHeadphonesSimple } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Analytics } from "../../../common/Analytics";
|
||||
import { SessionTtsAudioPlayer } from "./SessionTtsAudioPlayer";
|
||||
import { WebUrl } from "../../../common/WebUrl";
|
||||
import {
|
||||
GetPendingTtsJobCount,
|
||||
GetPendingTtsJobCountIsOk,
|
||||
GetPendingTtsJobCountSuccessResponse,
|
||||
} from "@storyteller/components/src/api/tts/GetPendingTtsJobCount";
|
||||
import {
|
||||
FrontendInferenceJobType,
|
||||
InferenceJob,
|
||||
} from "@storyteller/components/src/jobs/InferenceJob";
|
||||
import { useInferenceJobs, useLocalize, useSession } from "hooks";
|
||||
import { Button } from "components/common";
|
||||
import {
|
||||
faArrowDownToLine,
|
||||
faArrowRight,
|
||||
faStars,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import LoadingSpinner from "components/common/LoadingSpinner";
|
||||
|
||||
// TODO: This is duplicated in SessionTtsInferenceResultsList !
|
||||
// Default to querying every 15 seconds, but make it configurable serverside
|
||||
const DEFAULT_QUEUE_REFRESH_INTERVAL_MILLIS = 15000;
|
||||
|
||||
function SessionF5TtsResultsList() {
|
||||
const { t } = useLocalize("SessionVoiceConversionResultsList");
|
||||
const { t: t2 } = useLocalize("NewVC");
|
||||
const [pendingTtsJobs, setPendingTtsJobs] =
|
||||
useState<GetPendingTtsJobCountSuccessResponse>({
|
||||
success: true,
|
||||
pending_job_count: 0,
|
||||
cache_time: new Date(0), // NB: Epoch is used for vector clock's initial state
|
||||
refresh_interval_millis: DEFAULT_QUEUE_REFRESH_INTERVAL_MILLIS,
|
||||
});
|
||||
const { loggedInOrModal, loggedIn, sessionSubscriptions } = useSession();
|
||||
const { inferenceJobsByCategory } = useInferenceJobs();
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
const response = await GetPendingTtsJobCount();
|
||||
if (GetPendingTtsJobCountIsOk(response)) {
|
||||
if (
|
||||
response.cache_time.getTime() > pendingTtsJobs.cache_time.getTime()
|
||||
) {
|
||||
setPendingTtsJobs(response);
|
||||
}
|
||||
}
|
||||
};
|
||||
// TODO: We're having an outage and need to lower this.
|
||||
//const interval = setInterval(async () => fetch(), 15000);
|
||||
const refreshInterval = Math.max(
|
||||
DEFAULT_QUEUE_REFRESH_INTERVAL_MILLIS,
|
||||
pendingTtsJobs.refresh_interval_millis
|
||||
);
|
||||
const interval = setInterval(async () => fetch(), refreshInterval);
|
||||
fetch();
|
||||
return () => clearInterval(interval);
|
||||
}, [pendingTtsJobs]);
|
||||
|
||||
let results: Array<JSX.Element> = [];
|
||||
|
||||
// TODO(bt,2023-04-08): Clean this utter garbage duplication up.
|
||||
|
||||
// ============================= GENERIC INFERENCE =============================
|
||||
|
||||
inferenceJobsByCategory
|
||||
.get(FrontendInferenceJobType.F5Tts)
|
||||
.forEach((job: InferenceJob, index: number, array: InferenceJob[]) => {
|
||||
const reverseIndex = array.length - index;
|
||||
if (!job.maybeResultToken) {
|
||||
let cssStyle = "alert alert-secondary mb-0";
|
||||
let stateDescription = "Pending...";
|
||||
let loadingSpinner = null;
|
||||
let percentage = job.progressPercentage;
|
||||
|
||||
switch (job.jobState) {
|
||||
case JobState.PENDING:
|
||||
case JobState.UNKNOWN:
|
||||
stateDescription =
|
||||
job.maybeExtraStatusDescription == null
|
||||
? t("resultsProgressPending")
|
||||
: job.maybeExtraStatusDescription;
|
||||
loadingSpinner = (
|
||||
<LoadingSpinner
|
||||
className="fs-6"
|
||||
padding={false}
|
||||
thin={true}
|
||||
size={16}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case JobState.STARTED:
|
||||
cssStyle = "alert alert-success mb-0";
|
||||
stateDescription =
|
||||
job.maybeExtraStatusDescription == null
|
||||
? t("resultsProgressStarted")
|
||||
: job.maybeExtraStatusDescription;
|
||||
loadingSpinner = (
|
||||
<LoadingSpinner
|
||||
className="fs-6"
|
||||
padding={false}
|
||||
thin={true}
|
||||
size={16}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case JobState.ATTEMPT_FAILED:
|
||||
cssStyle = "alert alert-danger mb-0";
|
||||
stateDescription = `${
|
||||
(t("resultsProgressFail"), { 0: job.attemptCount || "0" })
|
||||
}}`;
|
||||
loadingSpinner = (
|
||||
<LoadingSpinner
|
||||
className="fs-6"
|
||||
padding={false}
|
||||
thin={true}
|
||||
size={16}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case JobState.COMPLETE_FAILURE:
|
||||
case JobState.DEAD:
|
||||
cssStyle = "alert alert-danger mb-0";
|
||||
// TODO(bt,2023-01-23): Translate when I can test it
|
||||
stateDescription = t("resultsProgressDead");
|
||||
break;
|
||||
case JobState.COMPLETE_SUCCESS:
|
||||
cssStyle = "message is-success mb-0";
|
||||
// Not sure why we're here instead of other branch!
|
||||
stateDescription = t("resultsProgressSuccess");
|
||||
break;
|
||||
}
|
||||
|
||||
results.push(
|
||||
<div key={job.jobToken}>
|
||||
<div className={`d-flex gap-2 ${cssStyle} fw-medium`.trim()}>
|
||||
{loadingSpinner}
|
||||
{stateDescription}
|
||||
{percentage !== 0 && ` (${percentage}%)`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
let audioLink = new BucketConfig().getGcsUrl(
|
||||
job.maybeResultPublicBucketMediaPath
|
||||
);
|
||||
let audioPermalink = `/media/${job.maybeResultToken}`;
|
||||
|
||||
let wavesurfers = <SessionTtsAudioPlayer filename={audioLink} />;
|
||||
|
||||
results.push(
|
||||
<div key={job.jobToken}>
|
||||
{/*<div className="message-header">
|
||||
<p>{job.title}</p>
|
||||
<button className="delete" aria-label="delete"></button>
|
||||
</div>*/}
|
||||
<div>
|
||||
<div className="panel panel-results p-3 gap-3 d-flex flex-column">
|
||||
<div>
|
||||
<div className="d-flex align-items-center gap-1 mb-2">
|
||||
<h6 className="mb-0 fw-semibold flex-grow-1">
|
||||
Audio #{reverseIndex}
|
||||
</h6>
|
||||
<Button
|
||||
iconFlip={true}
|
||||
variant="link"
|
||||
label="More details"
|
||||
className="fs-7"
|
||||
icon={faArrowRight}
|
||||
to={audioPermalink}
|
||||
/>
|
||||
</div>
|
||||
<p className="mb-0 fs-7 pt-1">{job.maybeRawInferenceText}</p>
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-3 align-items-center">
|
||||
{wavesurfers}
|
||||
<Button
|
||||
variant="action"
|
||||
small={true}
|
||||
square={true}
|
||||
icon={faArrowDownToLine}
|
||||
fontLarge={true}
|
||||
to={audioPermalink}
|
||||
onClick={() => {
|
||||
Analytics.ttsClickResultLink();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* <div className="mt-2">
|
||||
<Link
|
||||
to={ttsPermalink}
|
||||
onClick={() => {
|
||||
Analytics.ttsClickResultLink();
|
||||
}}
|
||||
className="fw-semibold"
|
||||
>
|
||||
<FontAwesomeIcon icon={faLink} className="me-2" />
|
||||
{t("resultsAudioShareDownload")}
|
||||
</Link>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let noResultsSection = (
|
||||
<div className="panel panel-inner text-center p-5 rounded-5 h-100">
|
||||
<div className="d-flex flex-column opacity-75 h-100 justify-content-center">
|
||||
<FontAwesomeIcon icon={faHeadphonesSimple} className="fs-3 mb-3" />
|
||||
<h5 className="fw-semibold">{t2("sessionResults.emptyTitle")}</h5>
|
||||
<p>{t2("sessionResults.emptySubtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (results.length === 0) {
|
||||
return <>{noResultsSection}</>;
|
||||
}
|
||||
|
||||
let upgradeNotice = <></>;
|
||||
|
||||
// Ask non-premium users to upgrade
|
||||
if (results.length !== 0 && !sessionSubscriptions?.hasPaidFeatures()) {
|
||||
if (loggedIn) {
|
||||
upgradeNotice = (
|
||||
<div className="d-flex flex-column gap-3 sticky-top zi-2">
|
||||
<div className="alert alert-primary alert-cta mb-0">
|
||||
<FontAwesomeIcon icon={faStars} className="me-2" />
|
||||
{t2("sessionResults.alertNoPlan")}{" "}
|
||||
<Link
|
||||
to={WebUrl.pricingPageWithReferer("nowait")}
|
||||
onClick={() => {
|
||||
Analytics.ttsTooSlowUpgradePremium();
|
||||
}}
|
||||
className="alert-link fw-semibold"
|
||||
>
|
||||
{t2("sessionResults.alertNoPlanLink")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
upgradeNotice = (
|
||||
<div className="d-flex flex-column gap-3 sticky-top zi-2">
|
||||
<div className="alert alert-warning alert-cta mb-0 d-flex align-items-center">
|
||||
<FontAwesomeIcon icon={faClock} className="me-2" />
|
||||
{t2("sessionResults.alertNonUser")}{" "}
|
||||
<Button
|
||||
onClick={() =>
|
||||
!loggedInOrModal({
|
||||
loginMessage: t2("modal.title.login"),
|
||||
signupMessage: t2("modal.title.signUp"),
|
||||
})
|
||||
}
|
||||
variant="link"
|
||||
className="alert-link fw-semibold ms-1"
|
||||
label={t2("sessionResults.alertNonUserLink")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column gap-3">
|
||||
{upgradeNotice}
|
||||
<div className="d-flex flex-column gap-3">{results}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SessionF5TtsResultsList };
|
||||
@@ -134,12 +134,10 @@ function FooterNav() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isOnF5TtsPage = location.pathname === "/f5-tts";
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isOnStudioPage && (
|
||||
<footer id="footer" className={isOnF5TtsPage ? "mt-0" : ""}>
|
||||
<footer id="footer">
|
||||
<Container type="panel" className="py-5">
|
||||
<div className="row g-5 m-0">
|
||||
<div className="col-12 col-lg-3 d-flex flex-column gap-4 align-items-center align-items-lg-start pb-2">
|
||||
|
||||
+3
-3
@@ -78,9 +78,9 @@ export default function VCRecordComponent(props: Props) {
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.dataLayer.push({
|
||||
"event": "upload_failure",
|
||||
"page": "/voice-conversion",
|
||||
"user_id": "$user_id"
|
||||
event: "upload_failure",
|
||||
page: "/voice-conversion",
|
||||
user_id: "$user_id",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+81
-25
@@ -55,42 +55,32 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 46px;
|
||||
width: 46px;
|
||||
padding: 20px;
|
||||
background-color: $secondary;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
border: 3px solid rgba($white, 0.1);
|
||||
}
|
||||
.icon {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
padding: 20px;
|
||||
background-color: $secondary;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 3px solid rgba($white, 0.2);
|
||||
}
|
||||
|
||||
.faq-section {
|
||||
background-color: rgba($secondary, 0.8);
|
||||
border-radius: 12px;
|
||||
padding: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
margin-top: 4rem;
|
||||
margin-bottom: 4rem;
|
||||
max-width: 1100px !important;
|
||||
|
||||
h2 {
|
||||
color: $white;
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
font-weight: bold;
|
||||
}
|
||||
margin-bottom: 2rem;
|
||||
border: 3px solid rgba($secondary, 0.8);
|
||||
|
||||
.faq-item {
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
color: $primary;
|
||||
color: lighten($primary, 5%);
|
||||
font-size: 20px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
@@ -121,3 +111,69 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.how-to-use-section {
|
||||
background-color: rgba($secondary, 0.8);
|
||||
border-radius: 12px;
|
||||
padding-bottom: 2rem;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 3rem;
|
||||
border: 3px solid rgba($white, 0.2);
|
||||
|
||||
.how-to-use-icon {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
padding: 14px;
|
||||
background-color: lighten($secondary, 10%);
|
||||
color: $white;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
border: 3px solid lighten($secondary, 20%);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
margin-bottom: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.how-to-use-item {
|
||||
margin-bottom: 30px;
|
||||
|
||||
h3 {
|
||||
color: lighten($primary, 6%);
|
||||
font-size: 20px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p {
|
||||
color: rgba($white, 0.8);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.how-to-use-section {
|
||||
padding: 20px;
|
||||
padding-top: 2.5rem;
|
||||
border-radius: 0px;
|
||||
|
||||
.how-to-use-item {
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
faArrowDownToLine,
|
||||
faFlask,
|
||||
faMicrophoneAlt,
|
||||
faTextSize,
|
||||
faWaveformLines,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Container,
|
||||
Label,
|
||||
Panel,
|
||||
TextArea,
|
||||
} from "components/common";
|
||||
import React, { useContext, useState } from "react";
|
||||
import useF5Store from "hooks/useF5Store";
|
||||
import F5RecordComponent from "./components/F5RecordComponent";
|
||||
import F5UploadComponent from "./components/F5UploadComponent";
|
||||
import { SessionF5TtsResultsList } from "v2/view/_common/SessionF5TtsResultsList";
|
||||
import { useInferenceJobs } from "hooks";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { FrontendInferenceJobType } from "@storyteller/components/src/jobs/InferenceJob";
|
||||
import {
|
||||
GenerateF5TtsAudio,
|
||||
GenerateF5TtsAudioRequest,
|
||||
GenerateF5TtsAudioResponse,
|
||||
} from "@storyteller/components/src/api/f5_tts/GenerateF5TtsAudio";
|
||||
import "./F5TTS.scss";
|
||||
import { AITools } from "components/marketing";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AppStateContext } from "components/providers/AppStateProvider";
|
||||
import { usePrefixedDocumentTitle } from "common/UsePrefixedDocumentTitle";
|
||||
|
||||
export default function F5TTS() {
|
||||
const { sessionWrapper } = useContext(AppStateContext);
|
||||
const { enqueueInferenceJob } = useInferenceJobs();
|
||||
const {
|
||||
mediaUploadToken,
|
||||
setMediaUploadToken,
|
||||
hasUploadedFile,
|
||||
setHasUploadedFile,
|
||||
hasRecordedFile,
|
||||
setHasRecordedFile,
|
||||
formIsCleared,
|
||||
setFormIsCleared,
|
||||
setText,
|
||||
text,
|
||||
} = useF5Store();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [isRecordingAudio, setIsRecordingAudio] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
usePrefixedDocumentTitle("F5-TTS Zero-Shot Voice Cloning");
|
||||
|
||||
const textChange = ({ target }: { target: any }) => {
|
||||
setText(target.value);
|
||||
};
|
||||
|
||||
const handleConvert = async (ev: React.FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
if (!mediaUploadToken) return;
|
||||
|
||||
const request: GenerateF5TtsAudioRequest = {
|
||||
uuid_idempotency_token: uuidv4(),
|
||||
source_media_file_token: mediaUploadToken,
|
||||
inference_text: text,
|
||||
creator_set_visibility: "public",
|
||||
};
|
||||
|
||||
try {
|
||||
const response: GenerateF5TtsAudioResponse = await GenerateF5TtsAudio(
|
||||
"",
|
||||
request
|
||||
);
|
||||
if (response) {
|
||||
console.log("TTS queued successfully:", response.inference_job_token);
|
||||
enqueueInferenceJob(
|
||||
response.inference_job_token,
|
||||
FrontendInferenceJobType.F5Tts
|
||||
);
|
||||
setIsGenerating(false);
|
||||
} else {
|
||||
console.error("Error queuing TTS");
|
||||
setIsGenerating(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unexpected error:", error);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container type="panel" className="mt-3 mt-lg-5">
|
||||
<Panel padding={true} className="p-lg-5">
|
||||
<form onSubmit={handleConvert}>
|
||||
<div className="d-flex flex-column flex-lg-row gap-3 align-items-center mb-3 text-center text-lg-start">
|
||||
<FontAwesomeIcon icon={faMicrophoneAlt} className="icon" />
|
||||
<div>
|
||||
<div className="d-flex gap-2 gap-lg-3 align-items-center justify-content-center justify-content-lg-start flex-wrap mb-1">
|
||||
<h1 className="fw-bold fs-1 mb-0">F5-TTS Voice Cloning</h1>
|
||||
<Badge label="Beta" icon={faFlask} color="gray" />
|
||||
</div>
|
||||
<p
|
||||
className="opacity-75 fw-medium"
|
||||
style={{ fontSize: "18px" }}
|
||||
>
|
||||
Instantly generate text-to-speech audio by uploading a short
|
||||
audio clip of any voice you choose.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-column gap-3 pt-3 pt-lg-5">
|
||||
<div className="row g-5">
|
||||
<div className="d-flex flex-column gap-3 col-12 col-lg-6">
|
||||
<div>
|
||||
<Label label="Reference Audio" />
|
||||
<div className="d-flex flex-column gap-3">
|
||||
{!hasUploadedFile && (
|
||||
<div>
|
||||
<F5RecordComponent
|
||||
setMediaUploadToken={setMediaUploadToken}
|
||||
formIsCleared={formIsCleared}
|
||||
setFormIsCleared={setFormIsCleared}
|
||||
setHasRecordedFile={setHasRecordedFile}
|
||||
hasRecordedFile={hasRecordedFile}
|
||||
setIsRecordingAudio={setIsRecordingAudio}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasUploadedFile && !hasRecordedFile && (
|
||||
<div className="d-flex gap-3 align-items-center">
|
||||
<hr className="w-100" />
|
||||
<span className="opacity-75 fw-medium">or</span>
|
||||
<hr className="w-100" />
|
||||
</div>
|
||||
)}
|
||||
{!hasRecordedFile && (
|
||||
<div>
|
||||
<div className="upload-component">
|
||||
<F5UploadComponent
|
||||
setMediaUploadToken={setMediaUploadToken}
|
||||
formIsCleared={formIsCleared}
|
||||
setFormIsCleared={setFormIsCleared}
|
||||
setHasUploadedFile={setHasUploadedFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<TextArea
|
||||
label="Enter Text"
|
||||
onChange={textChange}
|
||||
value={text}
|
||||
rows={10}
|
||||
placeholder="Type what you want the voice to say..."
|
||||
resize={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button
|
||||
icon={faWaveformLines}
|
||||
label="Generate Speech"
|
||||
type="submit"
|
||||
isLoading={isGenerating}
|
||||
disabled={!mediaUploadToken || !text}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="d-flex flex-column">
|
||||
<Label label="Output" />
|
||||
<div className="d-flex flex-column session-f5-section">
|
||||
<SessionF5TtsResultsList />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
</Container>
|
||||
|
||||
<Container type="panel">
|
||||
<div className="how-to-use-section p-4">
|
||||
<h2 className="fw-bold mb-5">How to Use F5-TTS</h2>
|
||||
<div className="row g-5">
|
||||
<div className="col-md-4 how-to-use-item">
|
||||
<FontAwesomeIcon
|
||||
icon={faWaveformLines}
|
||||
className="how-to-use-icon"
|
||||
/>
|
||||
<h3>Step 1: Upload Your Audio</h3>
|
||||
<p>
|
||||
In the panel above, start by adding a reference audio, either
|
||||
record your own voice or upload an audio file. This audio will
|
||||
be used by F5-TTS to clone the voice, enabling the generation of
|
||||
speech that closely resembles the reference voice. For optimal
|
||||
results, ensure the audio is clear and of high quality.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-md-4 how-to-use-item">
|
||||
<FontAwesomeIcon icon={faTextSize} className="how-to-use-icon" />
|
||||
<h3>Step 2: Enter Your Text</h3>
|
||||
<p>
|
||||
Next, input the text you wish to convert into speech. This text
|
||||
will be synthesized using the voice from your reference audio,
|
||||
allowing you to create personalized audio content. Ensure your
|
||||
text is clear and concise for the best results.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-md-4 how-to-use-item">
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowDownToLine}
|
||||
className="how-to-use-icon"
|
||||
/>
|
||||
<h3>Step 3: Generate and Save</h3>
|
||||
<p>
|
||||
With your audio and text prepared, click 'Generate Speech' to
|
||||
activate F5-TTS and transform your text into lifelike speech.
|
||||
Once the process is complete, you can listen to the synthesized
|
||||
audio directly in the output panel above. If you're happy with
|
||||
the result, click the download button to save the audio file and
|
||||
use it in your projects!
|
||||
{!sessionWrapper.isLoggedIn() && (
|
||||
<span>
|
||||
{" "}
|
||||
If you want your results to be kept,{" "}
|
||||
<Link
|
||||
to="/signup?redirect=/f5-tts"
|
||||
className="fw-medium text-red text-decoration-underline"
|
||||
>
|
||||
sign up
|
||||
</Link>{" "}
|
||||
and they will be saved to your account!
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Container type="panel">
|
||||
<Panel padding={true} className="p-4 faq-section">
|
||||
<h2 className="fw-bold mb-5">Frequently Asked Questions</h2>
|
||||
<div className="row g-5">
|
||||
<div className="faq-item">
|
||||
<h3>What is F5-TTS?</h3>
|
||||
<p>
|
||||
F5-TTS is an AI-driven text-to-speech tool that transforms
|
||||
written text into lifelike speech. With real-time processing,
|
||||
it’s great for generating dynamic audio content, whether it's
|
||||
for voice-overs, digital storytelling, or any other project that
|
||||
requires high-quality spoken output.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="my-3" />
|
||||
<div className="faq-item">
|
||||
<h3>How does F5-TTS work?</h3>
|
||||
<p>
|
||||
F5-TTS leverages advanced AI techniques, like Flow Matching and
|
||||
Diffusion Transformers, to turn text into speech. It skips some
|
||||
of the traditional steps like phoneme alignment and duration
|
||||
prediction, producing more natural-sounding audio directly from
|
||||
your input text.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="my-3" />
|
||||
<div className="faq-item">
|
||||
<h3>What kind of audio quality can I expect from F5-TTS?</h3>
|
||||
<p>
|
||||
F5-TTS delivers high-quality audio with clear, natural
|
||||
intonation, making it a perfect fit for professional projects
|
||||
like podcasts, audiobooks, and educational content. The speech
|
||||
it generates is crisp and lifelike, designed to meet the
|
||||
standards of any polished audio production.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="my-3" />
|
||||
<div className="faq-item">
|
||||
<h3>Can F5-TTS be used for voice-over work?</h3>
|
||||
<p>
|
||||
Absolutely! F5-TTS is a fantastic tool for voice-over
|
||||
production. Its zero-shot voice cloning feature allows you to
|
||||
create different voices for various characters or narrators, and
|
||||
it even supports emotional expression to add extra nuance and
|
||||
depth to your audio content.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Container>
|
||||
|
||||
<Container type="panel" className="pt-5 mt-5">
|
||||
<Panel clear={true}>
|
||||
<h2 className="fw-bold mb-3">Try our other AI tools</h2>
|
||||
<AITools />
|
||||
</Panel>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import "./VoiceDesignerV2.scss";
|
||||
import "./F5TTS.scss";
|
||||
import { Button, Container, Panel } from "components/common";
|
||||
import {
|
||||
faArrowRight,
|
||||
@@ -80,7 +80,7 @@ export default function VoiceDesignerV2(): JSX.Element {
|
||||
>
|
||||
<Panel padding={true}>
|
||||
<div className="countdown-container">
|
||||
<FontAwesomeIcon icon={faMicrophoneAlt} className="icon" />
|
||||
<FontAwesomeIcon icon={faMicrophoneAlt} className="icon mb-3" />
|
||||
<h1 className="fw-bold mb-1">F5-TTS Voice Cloning</h1>
|
||||
<p className="description">
|
||||
New zero-shot voice cloning coming soon...
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useAudioRecorder } from "react-audio-voice-recorder";
|
||||
import { InputVcAudioPlayer } from "v2/view/_common/InputVcAudioPlayer";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircle, faFileArrowUp } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
UploadAudio,
|
||||
UploadAudioIsOk,
|
||||
UploadAudioRequest,
|
||||
} from "@storyteller/components/src/api/upload/UploadAudio";
|
||||
import { faCheck, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useLocalize, useF5Store } from "hooks";
|
||||
import { Button } from "components/common";
|
||||
|
||||
interface Props {
|
||||
setMediaUploadToken: (token?: string) => void;
|
||||
formIsCleared: boolean;
|
||||
setFormIsCleared: (cleared: boolean) => void;
|
||||
setHasRecordedFile: (hasRecordedFile: boolean) => void;
|
||||
hasRecordedFile: boolean;
|
||||
setIsRecordingAudio: (isRecordingAudio: boolean) => void;
|
||||
}
|
||||
|
||||
export default function VCRecordComponent(props: Props) {
|
||||
const { t } = useLocalize("NewVC");
|
||||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
const { startRecording, stopRecording, recordingBlob, isRecording } =
|
||||
useAudioRecorder();
|
||||
|
||||
const {
|
||||
recordingBlobStore,
|
||||
setRecordingBlobStore,
|
||||
isUploadDisabled,
|
||||
setIsUploadDisabled,
|
||||
} = useF5Store();
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordingBlob) {
|
||||
return;
|
||||
} else setRecordingBlobStore(recordingBlob);
|
||||
}, [recordingBlob, setRecordingBlobStore]);
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
startRecording();
|
||||
props.setIsRecordingAudio(true);
|
||||
};
|
||||
|
||||
const handleStopRecording = async (blob: any) => {
|
||||
stopRecording();
|
||||
props.setFormIsCleared(false);
|
||||
setIsUploadDisabled(false);
|
||||
props.setHasRecordedFile(true);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
stopRecording();
|
||||
props.setMediaUploadToken(undefined);
|
||||
props.setFormIsCleared(true);
|
||||
props.setHasRecordedFile(false);
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const request: UploadAudioRequest = {
|
||||
uuid_idempotency_token: uuidv4(),
|
||||
file: recordingBlobStore,
|
||||
source: "device",
|
||||
};
|
||||
|
||||
setUploadLoading(true);
|
||||
|
||||
let result = await UploadAudio(request);
|
||||
|
||||
if (UploadAudioIsOk(result)) {
|
||||
props.setMediaUploadToken(result.upload_token);
|
||||
props.setFormIsCleared(false);
|
||||
setIsUploadDisabled(true);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.dataLayer.push({
|
||||
event: "upload_failure",
|
||||
page: "/voice-conversion",
|
||||
user_id: "$user_id",
|
||||
});
|
||||
}
|
||||
|
||||
setUploadLoading(false);
|
||||
};
|
||||
|
||||
const enableMediaReview =
|
||||
!props.formIsCleared && recordingBlobStore !== undefined;
|
||||
const enableUploadButton =
|
||||
!props.formIsCleared && recordingBlobStore !== undefined && !isRecording;
|
||||
|
||||
const speakButtonClass = isUploadDisabled
|
||||
? "btn btn-uploaded w-100 disabled"
|
||||
: "btn btn-primary w-100";
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column gap-3" id="record-audio">
|
||||
{isRecording ? (
|
||||
<button
|
||||
className="btn btn-secondary py-3"
|
||||
onClick={handleStopRecording}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<div
|
||||
className="spinner-grow spinner-grow-sm text-danger me-2"
|
||||
role="status"
|
||||
>
|
||||
<span className="visually-hidden">Recording...</span>
|
||||
</div>
|
||||
{t("button.stopRecord")}
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
icon={faCircle}
|
||||
iconClassName="text-danger"
|
||||
variant="secondary"
|
||||
label={
|
||||
props.hasRecordedFile
|
||||
? t("button.rerecord")
|
||||
: t("button.startRecord")
|
||||
}
|
||||
onClick={handleStartRecording}
|
||||
className="py-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{enableMediaReview ? (
|
||||
<>
|
||||
<RecordedAudioComponent recordingBlob={recordingBlobStore} />
|
||||
|
||||
<div className="d-flex gap-3">
|
||||
<button
|
||||
className={speakButtonClass}
|
||||
onClick={handleUpload}
|
||||
type="submit"
|
||||
disabled={
|
||||
isUploadDisabled || uploadLoading || !enableUploadButton
|
||||
}
|
||||
>
|
||||
{isUploadDisabled ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faCheck} className="me-2" />
|
||||
{t("button.recordUploaded")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
|
||||
{t("button.recordUpload")}
|
||||
</>
|
||||
)}
|
||||
{uploadLoading && <LoadingIcon />}
|
||||
</button>
|
||||
<button className="btn btn-destructive w-100" onClick={handleClear}>
|
||||
<FontAwesomeIcon icon={faTrash} className="me-2" />
|
||||
{t("button.recordClear")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RecorderProps {
|
||||
recordingBlob: any;
|
||||
}
|
||||
|
||||
function RecordedAudioComponent(props: RecorderProps) {
|
||||
// Only generate the URL on change.
|
||||
const audioLink = useMemo(() => {
|
||||
if (!props.recordingBlob) {
|
||||
return;
|
||||
}
|
||||
return URL.createObjectURL(props.recordingBlob);
|
||||
}, [props.recordingBlob]);
|
||||
|
||||
if (!props.recordingBlob) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel panel-inner rounded p-3">
|
||||
<InputVcAudioPlayer filename={audioLink as string} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LoadingIcon: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm ms-3"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
faCheck,
|
||||
faFileArrowUp,
|
||||
faFileAudio,
|
||||
faTrash,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { useState } from "react";
|
||||
import { FileUploader } from "react-drag-drop-files";
|
||||
import { InputVcAudioPlayer } from "v2/view/_common/InputVcAudioPlayer";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
UploadAudio,
|
||||
UploadAudioIsOk,
|
||||
UploadAudioRequest,
|
||||
} from "@storyteller/components/src/api/upload/UploadAudio";
|
||||
import { useF5Store, useLocalize } from "hooks";
|
||||
|
||||
const FILE_TYPES = ["MP3", "WAV", "FLAC", "OGG"];
|
||||
|
||||
interface Props {
|
||||
setMediaUploadToken: (token?: string) => void;
|
||||
formIsCleared: boolean;
|
||||
setFormIsCleared: (cleared: boolean) => void;
|
||||
setHasUploadedFile: (hasUploadedFile: boolean) => void;
|
||||
}
|
||||
|
||||
function F5UploadComponent(props: Props) {
|
||||
const { t } = useLocalize("UploadComponent");
|
||||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
const [uploaderKey, setUploaderKey] = useState(uuidv4());
|
||||
const {
|
||||
isUploadDisabled,
|
||||
setIsUploadDisabled,
|
||||
file,
|
||||
setFile,
|
||||
audioLink,
|
||||
setAudioLink,
|
||||
} = useF5Store();
|
||||
|
||||
const handleChange = (file: any) => {
|
||||
console.log("handle change");
|
||||
setFile(file);
|
||||
const audioUrl = URL.createObjectURL(file);
|
||||
setAudioLink(audioUrl ?? "");
|
||||
props.setFormIsCleared(false);
|
||||
setIsUploadDisabled(false);
|
||||
props.setHasUploadedFile(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.classList.add("upload-zone-drag");
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.classList.remove("upload-zone-drag");
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setFile(null);
|
||||
setAudioLink("");
|
||||
setIsUploadDisabled(false);
|
||||
props.setMediaUploadToken(undefined);
|
||||
props.setFormIsCleared(true);
|
||||
props.setHasUploadedFile(false);
|
||||
setUploaderKey(uuidv4());
|
||||
};
|
||||
|
||||
const handleUploadFile = async () => {
|
||||
if (file === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setUploadLoading(true);
|
||||
|
||||
const request: UploadAudioRequest = {
|
||||
uuid_idempotency_token: uuidv4(),
|
||||
file: file,
|
||||
source: "file",
|
||||
};
|
||||
|
||||
let result = await UploadAudio(request);
|
||||
|
||||
if (UploadAudioIsOk(result)) {
|
||||
setIsUploadDisabled(true);
|
||||
props.setMediaUploadToken(result.upload_token);
|
||||
props.setFormIsCleared(false);
|
||||
}
|
||||
|
||||
setUploadLoading(false);
|
||||
};
|
||||
|
||||
const fileSize =
|
||||
file && file.size >= 1024 * 1024
|
||||
? (file.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
: file
|
||||
? `${Math.floor(file.size / 1024)} KB`
|
||||
: null;
|
||||
|
||||
const uploadBtnClass = isUploadDisabled
|
||||
? "btn fw-medium btn-uploaded w-100 disabled"
|
||||
: "btn fw-medium btn-primary w-100";
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column gap-3">
|
||||
<FileUploader
|
||||
key={uploaderKey}
|
||||
handleChange={handleChange}
|
||||
name="file"
|
||||
types={FILE_TYPES}
|
||||
maxSize={50}
|
||||
children={
|
||||
<div
|
||||
className={`panel panel-inner upload-zone d-flex align-items-center justify-content-center p-4 ${
|
||||
!file ? "empty-zone-f5" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<div className="me-3">
|
||||
{file ? (
|
||||
<FontAwesomeIcon icon={faFileAudio} className="upload-icon" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faFileArrowUp} className="upload-icon" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-0">
|
||||
{file ? (
|
||||
<span className="filename" title={file.name}>
|
||||
{file.name.slice(0, file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<u className="fw-medium">{t("uploadFileTextUpload")}</u>{" "}
|
||||
{t("uploadFileTextDrop")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex gap-1">
|
||||
<div>
|
||||
{file ? (
|
||||
<p>
|
||||
<span className="opacity-50">
|
||||
{file && `${file.name.split(".").pop().toUpperCase()}`}{" "}
|
||||
file size: {fileSize}
|
||||
</span>{" "}
|
||||
<u className="fw-medium opacity-100 ms-1">
|
||||
{t("uploadChangeFile")}
|
||||
</u>
|
||||
</p>
|
||||
) : (
|
||||
<p className="opacity-50">
|
||||
{FILE_TYPES.join(", ").toString()} supported
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="panel panel-inner rounded p-3">
|
||||
<InputVcAudioPlayer filename={audioLink as string} />
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{file ? (
|
||||
<div className="d-flex gap-3">
|
||||
<button
|
||||
className={uploadBtnClass}
|
||||
onClick={() => {
|
||||
handleUploadFile();
|
||||
}}
|
||||
type="submit"
|
||||
disabled={uploadLoading || isUploadDisabled}
|
||||
>
|
||||
{isUploadDisabled ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faCheck} className="me-2" />
|
||||
{t("uploadButtonUploaded")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
|
||||
{t("uploadButtonUploadAudio")}
|
||||
</>
|
||||
)}
|
||||
{uploadLoading && <LoadingIcon />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-destructive w-100 fw-medium"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="me-2" />
|
||||
{t("uploadButtonClear")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LoadingIcon: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm ms-3"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default F5UploadComponent;
|
||||
Reference in new issue
Block a user