Merge branch 'master' into bflat-prettier-configs
This commit is contained in:
59 files changed
+483
-438
No files matched your search
@@ -39,6 +39,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-spring/types": "^9.7.3",
|
||||
"moment": "^2.29.4",
|
||||
"universal-cookie": "^6.1.1",
|
||||
"yarn": "^1.22.19"
|
||||
},
|
||||
|
||||
@@ -15,9 +15,10 @@ export interface InferenceInfo {
|
||||
}
|
||||
|
||||
export interface ByQueueStats {
|
||||
pending_svc_jobs: number,
|
||||
pending_rvc_jobs: number,
|
||||
pending_face_animation_jobs: number,
|
||||
pending_rvc_jobs: number,
|
||||
pending_svc_jobs: number,
|
||||
pending_tacotron2_jobs: number,
|
||||
}
|
||||
|
||||
export interface LegacyTtsInfo {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import MakeRequest from "../../MakeRequest";
|
||||
|
||||
export interface DreateVoiceRequest {
|
||||
export interface DeleteSampleRequest {
|
||||
set_delete: boolean;
|
||||
as_mod: boolean;
|
||||
}
|
||||
|
||||
export interface DreateVoiceResponse {
|
||||
export interface DeleteSampleResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export const DeleteDataset = MakeRequest<string, DreateVoiceRequest, DreateVoiceResponse>({
|
||||
export const DeleteSample = MakeRequest<string, DeleteSampleRequest, DeleteSampleResponse>({
|
||||
method: "DELETE",
|
||||
routingFunction: (sampleToken: string) => `/v1/voice_designer/sample/${ sampleToken }/delete`,
|
||||
});
|
||||
+2
-13
@@ -3,19 +3,8 @@ import MakeRequest from "../../MakeRequest";
|
||||
export interface ListSamplesForDatasetRequest {}
|
||||
|
||||
export interface ListSamplesForDatasetResponse {
|
||||
sample_token: string,
|
||||
|
||||
media_file_token: string,
|
||||
media_type: string,
|
||||
|
||||
public_bucket_directory_hash: string,
|
||||
maybe_public_bucket_prefix?: string,
|
||||
maybe_public_bucket_extension?: string,
|
||||
|
||||
maybe_creator_user_token?: string,
|
||||
|
||||
created_at: Date,
|
||||
updated_at: Date,
|
||||
samples: any,
|
||||
success: true
|
||||
}
|
||||
|
||||
export const ListSamplesForDataset = MakeRequest<string, ListSamplesForDatasetRequest, ListSamplesForDatasetResponse>({
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "ডাউনলোড ফাইল",
|
||||
"generate": "তৈরি করুন",
|
||||
"makeAnother": "আরেকটা তৈরি কর"
|
||||
"makeAnother": "আরেকটা তৈরি কর",
|
||||
"viewResult": "ফলাফল দেখুন",
|
||||
"yourJobs": "আপনার কাজ"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "অ্যানিমেশন সম্পূর্ণ!",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"heroButtonUpgradePlan": "আপগ্রেড পরিকল্পনা",
|
||||
"heroText": "আপনি যা চান তা বলে আপনার প্রিয় চরিত্রের অডিও বা ভিডিও তৈরি করতে FakeYou এর ডিপফেক প্রযুক্তি ব্যবহার করুন।",
|
||||
"heroTitle": "এআই মিউজিক, টেক্সট টু স্পিচ এবং ভয়েস টু ভয়েস",
|
||||
"productFaceAnimatorText": "অডিওর সাথে মুখগুলিকে অ্যানিমেট করুন",
|
||||
"productFaceAnimatorText": "অডিও দিয়ে মুখগুলোকে অ্যানিমেট করুন",
|
||||
"productFaceAnimatorTitle": "ফেস অ্যানিমেটর",
|
||||
"productNewTag": "নতুন",
|
||||
"productTtsText": "পাঠ্য থেকে অডিও তৈরি করুন",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "ভয়েস ভাল শোনাচ্ছে",
|
||||
"ratingGreat": "ভয়েস দুর্দান্ত শোনাচ্ছে",
|
||||
"ratingLabel": "রেটিং:",
|
||||
"ratingNone": "ভয়েস রেট করা হয়নি",
|
||||
"ratingOk": "ভয়েস ঠিক আছে শোনাচ্ছে"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "Download-Datei",
|
||||
"generate": "Generieren",
|
||||
"makeAnother": "Mach etwas anderes"
|
||||
"makeAnother": "Mach etwas anderes",
|
||||
"viewResult": "Zeige Ergebnis",
|
||||
"yourJobs": "Ihre Jobs"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "Animation abgeschlossen!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "Stimme klingt gut",
|
||||
"ratingGreat": "Stimme klingt großartig",
|
||||
"ratingLabel": "Bewertung:",
|
||||
"ratingNone": "Stimme nicht bewertet",
|
||||
"ratingOk": "Die Stimme klingt in Ordnung"
|
||||
}
|
||||
@@ -3,14 +3,14 @@
|
||||
"audio": "Audio",
|
||||
"image": "Image",
|
||||
"subtitle": "Select and image with a clear face, and an audio sample, and generate a lipsynced video.",
|
||||
"title": "Face Animator",
|
||||
"yourJobs": "Your jobs"
|
||||
"title": "Face Animator"
|
||||
},
|
||||
"inputs": {
|
||||
"downloadFile": "Download File",
|
||||
"generate": "Generate",
|
||||
"makeAnother": "Make another",
|
||||
"viewResult": "View result"
|
||||
"viewResult": "View result",
|
||||
"yourJobs": "Your jobs"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "Animation complete!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "Voice Sounds Good",
|
||||
"ratingGreat": "Voice Sounds Great",
|
||||
"ratingLabel": "Rating:",
|
||||
"ratingNone": "Voice Not Rated",
|
||||
"ratingOk": "Voice Sounds Okay"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "Descargar archivo",
|
||||
"generate": "Generar",
|
||||
"makeAnother": "Hacer otro"
|
||||
"makeAnother": "Hacer otro",
|
||||
"viewResult": "Ver resultado",
|
||||
"yourJobs": "Tus trabajos"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "¡Animación completa!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "La voz suena bien",
|
||||
"ratingGreat": "La voz suena genial",
|
||||
"ratingLabel": "Clasificación:",
|
||||
"ratingNone": "Voz no clasificada",
|
||||
"ratingOk": "La voz suena bien"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "Télécharger un fichier",
|
||||
"generate": "Générer",
|
||||
"makeAnother": "Faire un autre"
|
||||
"makeAnother": "Faire un autre",
|
||||
"viewResult": "Voir résultat",
|
||||
"yourJobs": "Vos emplois"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "Animation terminée !",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "La voix sonne bien",
|
||||
"ratingGreat": "La voix sonne bien",
|
||||
"ratingLabel": "Notation:",
|
||||
"ratingNone": "Voix non classée",
|
||||
"ratingOk": "La voix sonne bien"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "डाउनलोड फ़ाइल",
|
||||
"generate": "उत्पन्न",
|
||||
"makeAnother": "एक और बनाने"
|
||||
"makeAnother": "एक और बनाने",
|
||||
"viewResult": "परिणाम देख",
|
||||
"yourJobs": "आपकी नौकरियाँ"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "एनिमेशन पूर्ण!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "आवाज़ अच्छी लगती है",
|
||||
"ratingGreat": "आवाज़ बहुत अच्छी लगती है",
|
||||
"ratingLabel": "रेटिंग:",
|
||||
"ratingNone": "आवाज रेटेड नहीं",
|
||||
"ratingOk": "आवाज़ ठीक लगती है"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "Unduh berkas",
|
||||
"generate": "Menghasilkan",
|
||||
"makeAnother": "Buat yang lain"
|
||||
"makeAnother": "Buat yang lain",
|
||||
"viewResult": "Lihat hasil",
|
||||
"yourJobs": "Pekerjaan Anda"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "Animasi selesai!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "Suara Kedengarannya Bagus",
|
||||
"ratingGreat": "Suara Kedengarannya Hebat",
|
||||
"ratingLabel": "Peringkat:",
|
||||
"ratingNone": "Suara Tidak Dinilai",
|
||||
"ratingOk": "Suara Kedengarannya Oke"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "Download file",
|
||||
"generate": "creare",
|
||||
"makeAnother": "Fanne un'altro"
|
||||
"makeAnother": "Fanne un'altro",
|
||||
"viewResult": "Visualizza risultato",
|
||||
"yourJobs": "I tuoi lavori"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "Animazione completata!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "La voce suona bene",
|
||||
"ratingGreat": "La voce suona alla grande",
|
||||
"ratingLabel": "Valutazione:",
|
||||
"ratingNone": "Voce non classificata",
|
||||
"ratingOk": "La voce suona bene"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "ダウンロードファイル",
|
||||
"generate": "生成する",
|
||||
"makeAnother": "別のものを作る"
|
||||
"makeAnother": "別のものを作る",
|
||||
"viewResult": "結果を見る",
|
||||
"yourJobs": "あなたの仕事"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "アニメーション完成!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "声が良い",
|
||||
"ratingGreat": "声が素晴らしい",
|
||||
"ratingLabel": "評価:",
|
||||
"ratingNone": "音声が評価されていません",
|
||||
"ratingOk": "声は大丈夫です"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "파일 다운로드",
|
||||
"generate": "생성하다",
|
||||
"makeAnother": "다른 것을 만드세요"
|
||||
"makeAnother": "다른 것을 만드세요",
|
||||
"viewResult": "결과 보기",
|
||||
"yourJobs": "귀하의 직업"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "애니메이션 완성!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "목소리가 좋다",
|
||||
"ratingGreat": "목소리가 좋아요",
|
||||
"ratingLabel": "평가:",
|
||||
"ratingNone": "음성 등급 없음",
|
||||
"ratingOk": "목소리는 괜찮아요"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "⇬ Fazer download do arquivo",
|
||||
"generate": "Gerar",
|
||||
"makeAnother": "Faça outro"
|
||||
"makeAnother": "Faça outro",
|
||||
"viewResult": "Ver resultado",
|
||||
"yourJobs": "Seus empregos"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "Animação concluída!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "Voz soa bem",
|
||||
"ratingGreat": "A voz parece ótima",
|
||||
"ratingLabel": "Avaliação:",
|
||||
"ratingNone": "Voz não avaliada",
|
||||
"ratingOk": "Voz soa bem"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "Загрузить файл",
|
||||
"generate": "Генерировать",
|
||||
"makeAnother": "Сделайте еще один"
|
||||
"makeAnother": "Сделайте еще один",
|
||||
"viewResult": "Посмотреть результат",
|
||||
"yourJobs": "Ваши вакансии"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "Анимация завершена!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "Голос звучит хорошо",
|
||||
"ratingGreat": "Голос звучит великолепно",
|
||||
"ratingLabel": "Рейтинг:",
|
||||
"ratingNone": "Голос без рейтинга",
|
||||
"ratingOk": "Голос звучит нормально"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "ดาวน์โหลดไฟล์",
|
||||
"generate": "สร้าง",
|
||||
"makeAnother": "ทำอีก"
|
||||
"makeAnother": "ทำอีก",
|
||||
"viewResult": "ดูผลลัพธ์",
|
||||
"yourJobs": "งานของคุณ"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "แอนิเมชั่นเสร็จสมบูรณ์!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "เสียงฟังดูดี",
|
||||
"ratingGreat": "เสียงฟังดูดีมาก",
|
||||
"ratingLabel": "เรตติ้ง:",
|
||||
"ratingNone": "เสียงไม่ได้รับการจัดอันดับ",
|
||||
"ratingOk": "เสียงฟังดูโอเค"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "Dosyayı indir",
|
||||
"generate": "Oluştur",
|
||||
"makeAnother": "Başka bir tane yap"
|
||||
"makeAnother": "Başka bir tane yap",
|
||||
"viewResult": "Sonucu göster",
|
||||
"yourJobs": "İşleriniz"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "Animasyon tamamlandı!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "Ses Kulağa İyi Geliyor",
|
||||
"ratingGreat": "Sesi harika geliyor",
|
||||
"ratingLabel": "Değerlendirme:",
|
||||
"ratingNone": "Ses Derecelendirilmedi",
|
||||
"ratingOk": "Ses Kulağa Sorunsuz Geliyor"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "Tải tập tin",
|
||||
"generate": "Phát ra",
|
||||
"makeAnother": "Làm một cái khác"
|
||||
"makeAnother": "Làm một cái khác",
|
||||
"viewResult": "Xem kết quả",
|
||||
"yourJobs": "Công việc của bạn"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "Hoạt hình hoàn tất!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "Giọng nói nghe hay",
|
||||
"ratingGreat": "Giọng nói nghe rất hay",
|
||||
"ratingLabel": "Xếp hạng:",
|
||||
"ratingNone": "Giọng nói không được xếp hạng",
|
||||
"ratingOk": "Giọng nói ổn"
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
"inputs": {
|
||||
"downloadFile": "下载文件",
|
||||
"generate": "产生",
|
||||
"makeAnother": "再做一个"
|
||||
"makeAnother": "再做一个",
|
||||
"viewResult": "查看结果",
|
||||
"yourJobs": "你的职位"
|
||||
},
|
||||
"status": {
|
||||
"animationComplete": "动画完成!",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"ratingGood": "声音听起来不错",
|
||||
"ratingGreat": "声音听起来很棒",
|
||||
"ratingLabel": "评分:",
|
||||
"ratingNone": "声音未评级",
|
||||
"ratingOk": "声音听起来不错"
|
||||
}
|
||||
@@ -146,6 +146,8 @@ interface State {
|
||||
|
||||
// Rollout of vocodes 2.0
|
||||
enableAlpha: boolean;
|
||||
|
||||
sessionFetched: boolean;
|
||||
sessionWrapper: SessionWrapper;
|
||||
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
|
||||
|
||||
@@ -237,6 +239,7 @@ class App extends React.Component<Props, State> {
|
||||
enableAlpha: enableAlpha,
|
||||
migrationMode: migrationMode,
|
||||
|
||||
sessionFetched: false,
|
||||
sessionWrapper: SessionWrapper.emptySession(),
|
||||
sessionSubscriptionsWrapper:
|
||||
SessionSubscriptionsWrapper.emptySubscriptions(),
|
||||
@@ -328,6 +331,7 @@ class App extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
querySessionSubscriptions = async () => {
|
||||
this.setState({ sessionFetched: true });
|
||||
const cookies = new Cookies();
|
||||
|
||||
const sessionSubscriptionsWrapper =
|
||||
@@ -836,7 +840,10 @@ class App extends React.Component<Props, State> {
|
||||
}}
|
||||
>
|
||||
<SessionProvider
|
||||
{...{ sessionWrapper: this.state.sessionWrapper }}
|
||||
{...{
|
||||
sessionWrapper: this.state.sessionWrapper,
|
||||
sessionFetched: this.state.sessionFetched
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.fy-audio-blob {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { a, useTransition } from "@react-spring/web";
|
||||
import { InputVcAudioPlayer } from "v2/view/_common/InputVcAudioPlayer";
|
||||
import { FileActions } from "components/common";
|
||||
import "./AudioBlobPreview.scss";
|
||||
|
||||
interface Props {
|
||||
blob?: any,
|
||||
clear?: any,
|
||||
file?: any,
|
||||
hideActions: boolean,
|
||||
onRest?: () => void,
|
||||
success?: any,
|
||||
submit?: () => void,
|
||||
working?: any
|
||||
}
|
||||
|
||||
export default function AudioBlobPreview({ clear, blob, file, hideActions, onRest, success, submit, working }: Props) {
|
||||
const transitions = useTransition(file, {
|
||||
config: { tension: 120, friction: 15 },
|
||||
from: { opacity: 0 },
|
||||
enter: { opacity: 1 },
|
||||
leave: { opacity: 0 },
|
||||
onRest,
|
||||
});
|
||||
|
||||
return transitions((style, i) => i ? <a.div {...{ className: "audio-details", style }}>
|
||||
<div {...{ className: "fy-audio-blob panel" }}>
|
||||
<InputVcAudioPlayer {...{ filename: blob as string }}/>
|
||||
</div>
|
||||
{
|
||||
!hideActions && <FileActions {...{ clear, success, submit, working }}/>
|
||||
}
|
||||
</a.div> : null );
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AudioBlobPreview';
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { a, useTransition } from '@react-spring/web';
|
||||
import { InputVcAudioPlayer } from "v2/view/_common/InputVcAudioPlayer";
|
||||
import { FileActions, FileDetails, FileLabel, FileWrapper } from 'components/common';
|
||||
// import { a, useTransition } from '@react-spring/web';
|
||||
// import { InputVcAudioPlayer } from "v2/view/_common/InputVcAudioPlayer";
|
||||
import { FileDetails, FileLabel, FileWrapper } from 'components/common';
|
||||
import { faFileAudio } from "@fortawesome/pro-solid-svg-icons";
|
||||
import './AudioInput.scss'
|
||||
|
||||
@@ -9,39 +9,22 @@ const fileTypes = ["MP3", "WAV", "FLAC", "OGG"];
|
||||
|
||||
interface Props {
|
||||
blob?: string;
|
||||
children?: any;
|
||||
clear?: (file?: any) => void;
|
||||
file?: any;
|
||||
hideActions?: boolean;
|
||||
hideDetails?: boolean;
|
||||
hideClearDetails?: boolean;
|
||||
inputProps?: any;
|
||||
onRest?: () => void;
|
||||
success?: boolean;
|
||||
submit?: () => void;
|
||||
working?: boolean;
|
||||
[x:string]: any;
|
||||
}
|
||||
|
||||
const n = () => {};
|
||||
|
||||
export default function AudioInput({ blob = "", clear = n, file, hideActions, hideClearDetails, inputProps, onRest = n, success = false, submit = n, working = false, ...rest }: Props) {
|
||||
|
||||
const transitions = useTransition(file, {
|
||||
config: { tension: 120, friction: 15 },
|
||||
from: { opacity: 0 },
|
||||
enter: { opacity: 1 },
|
||||
leave: { opacity: 0 },
|
||||
onRest,
|
||||
});
|
||||
|
||||
export default function AudioInput({ children, clear = n, file, hideDetails, hideClearDetails, inputProps, ...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>
|
||||
{ transitions((style, i) => i ? <a.div {...{ className: "audio-details", style }}>
|
||||
<div {...{ className: "panel panel-inner rounded p-3" }}>
|
||||
<InputVcAudioPlayer {...{ filename: blob as string }}/>
|
||||
</div>
|
||||
{ !hideActions && <FileActions {...{ clear, success, submit, working }}/> }
|
||||
</a.div> : null ) }
|
||||
{ children }
|
||||
</div>;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import AudioBlobPreview from "./AudioBlobPreview";
|
||||
import AudioInput from "./AudioInput";
|
||||
import BasicVideo from "./BasicVideo";
|
||||
import Button from "./Button";
|
||||
@@ -20,6 +21,7 @@ import TempInput from "./TempInput";
|
||||
import TempSelect from "./TempSelect";
|
||||
|
||||
export {
|
||||
AudioBlobPreview,
|
||||
AudioInput,
|
||||
BasicVideo,
|
||||
Button,
|
||||
|
||||
@@ -28,7 +28,7 @@ interface JobsListProps{
|
||||
export default function InferenceJobsList({ filter, onSelect, statusTxt, t, inferenceJobs }: JobsListProps) {
|
||||
// const { inferenceJobs = [] } = useInferenceJobs({ type: 0 });
|
||||
// const oldJobs = thejobs || inferenceJobs;
|
||||
console.log("🥬",inferenceJobs);
|
||||
|
||||
const [pending, pendingSet] = useState<GetPendingTtsJobCountSuccessResponse>({
|
||||
success: true,
|
||||
pending_job_count: 0,
|
||||
@@ -97,7 +97,6 @@ export default function InferenceJobsList({ filter, onSelect, statusTxt, t, infe
|
||||
{ jobs
|
||||
// .filter(filter)
|
||||
.map((job: any, key: number) => {
|
||||
console.log("🍇",job);
|
||||
return <div {...{ className: "panel face-animator-job", key }}>
|
||||
<FontAwesomeIcon {...{ className: `job-status-icon job-status-${job.statusIndex}`, icon: statusIcons[job.statusIndex] }}/>
|
||||
<div {...{ className: "job-details" }}>
|
||||
|
||||
@@ -114,9 +114,10 @@ export default function SideNav(props: SideNavProps) {
|
||||
total_pending_job_count: 0,
|
||||
pending_job_count: 0,
|
||||
by_queue: {
|
||||
pending_svc_jobs: 0,
|
||||
pending_rvc_jobs: 0,
|
||||
pending_face_animation_jobs: 0,
|
||||
pending_rvc_jobs: 0,
|
||||
pending_svc_jobs: 0,
|
||||
pending_tacotron2_jobs: 0,
|
||||
},
|
||||
},
|
||||
legacy_tts: {
|
||||
@@ -223,6 +224,11 @@ export default function SideNav(props: SideNavProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// NB(bt,2023-11-28): These are representative of tacotron2 jobs handled by two queueing systems:
|
||||
// The legacy queue (tts-inference-job) and the modern queue (inference-job). We'll add both totals
|
||||
// while we migrate off of the legacy system, then eventually kill the legacy statistic.
|
||||
const ttsQueuedCount = queueStats.legacy_tts.pending_job_count + queueStats.inference.by_queue.pending_tacotron2_jobs;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="sidebar-wrapper"
|
||||
@@ -360,7 +366,7 @@ export default function SideNav(props: SideNavProps) {
|
||||
<div>
|
||||
{t("queueTts")}:{" "}
|
||||
<span className="text-red">
|
||||
{queueStats.legacy_tts.pending_job_count}
|
||||
{ttsQueuedCount}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { SessionContext } from 'context';
|
||||
|
||||
interface Props {
|
||||
children?: any;
|
||||
sessionFetched: boolean;
|
||||
sessionWrapper?: any;
|
||||
}
|
||||
|
||||
export default function Session({ children, sessionWrapper }: Props) {
|
||||
return <SessionContext.Provider {...{ value: { sessionWrapper } }}>
|
||||
export default function Session({ children, sessionFetched, sessionWrapper }: Props) {
|
||||
return <SessionContext.Provider {...{ value: { sessionFetched, sessionWrapper } }}>
|
||||
{ children }
|
||||
</SessionContext.Provider>
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
interface SessionContextType {
|
||||
sessionFetched: boolean,
|
||||
sessionWrapper?: any
|
||||
}
|
||||
|
||||
export default createContext<SessionContextType>({});
|
||||
export default createContext<SessionContextType>({
|
||||
sessionFetched: false
|
||||
});
|
||||
@@ -12,21 +12,12 @@ interface Props {
|
||||
export default function useFile({ debug, onChange = n, onClear = n, onSubmit = n }: Props) {
|
||||
const [file, fileSet] = useState<any>(undefined);
|
||||
const [blob, blobSet] = useState<string>();
|
||||
const [status, statusSet] = useState<number>(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (file === undefined) { return false; }
|
||||
statusSet(1);
|
||||
const onUploadResult = await onSubmit(file);
|
||||
if (onUploadResult) { statusSet(2); }
|
||||
else { statusSet(3); }
|
||||
};
|
||||
const fileChange = (inputFile?: any) => {
|
||||
onChange(file);
|
||||
onChange(inputFile);
|
||||
fileSet(inputFile || null);
|
||||
blobSet(inputFile ? URL.createObjectURL(inputFile) : "");
|
||||
statusSet(0);
|
||||
};
|
||||
const inputChange = ({ target = {} }: { target: any }) => {
|
||||
fileChange(target.value);
|
||||
@@ -35,7 +26,6 @@ export default function useFile({ debug, onChange = n, onClear = n, onSubmit = n
|
||||
if (inputRef?.current?.value) inputRef.current.value = '';
|
||||
fileChange();
|
||||
onClear();
|
||||
statusSet(0);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -45,10 +35,6 @@ export default function useFile({ debug, onChange = n, onClear = n, onSubmit = n
|
||||
inputProps: {
|
||||
onChange: inputChange,
|
||||
inputRef
|
||||
},
|
||||
status,
|
||||
submit,
|
||||
success: status === 2,
|
||||
working: status === 1,
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,9 @@ import { useContext } from 'react';
|
||||
import { SessionContext } from 'context';
|
||||
|
||||
export default function useInferenceJobs() {
|
||||
const { sessionWrapper } = useContext(SessionContext);
|
||||
return sessionWrapper?.sessionStateResponse || {};
|
||||
const { sessionFetched, sessionWrapper } = useContext(SessionContext);
|
||||
return {
|
||||
sessionFetched,
|
||||
...sessionWrapper?.sessionStateResponse
|
||||
};
|
||||
};
|
||||
@@ -30,7 +30,7 @@ function RatingStats(props: Props) {
|
||||
icon={faStarExclamation}
|
||||
className="me-2 rating-icon"
|
||||
/>
|
||||
<p>{t("common.RatingStats.voice.notRated")}</p>
|
||||
<p>{t("ratingNone")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,9 +56,10 @@ function FooterNav(props: Props) {
|
||||
total_pending_job_count: 0,
|
||||
pending_job_count: 0,
|
||||
by_queue: {
|
||||
pending_svc_jobs: 0,
|
||||
pending_rvc_jobs: 0,
|
||||
pending_face_animation_jobs: 0,
|
||||
pending_rvc_jobs: 0,
|
||||
pending_svc_jobs: 0,
|
||||
pending_tacotron2_jobs: 0,
|
||||
},
|
||||
},
|
||||
legacy_tts: {
|
||||
@@ -122,6 +123,11 @@ function FooterNav(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// NB(bt,2023-11-28): These are representative of tacotron2 jobs handled by two queueing systems:
|
||||
// The legacy queue (tts-inference-job) and the modern queue (inference-job). We'll add both totals
|
||||
// while we migrate off of the legacy system, then eventually kill the legacy statistic.
|
||||
const ttsQueuedCount = queueStats.legacy_tts.pending_job_count + queueStats.inference.by_queue.pending_tacotron2_jobs;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<footer id="footer">
|
||||
@@ -130,7 +136,7 @@ function FooterNav(props: Props) {
|
||||
<div>
|
||||
TTS Queued:{" "}
|
||||
<span className="text-red">
|
||||
{queueStats.legacy_tts.pending_job_count}
|
||||
{ttsQueuedCount}
|
||||
</span>
|
||||
</div>
|
||||
<span className="opacity-25">•</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { AudioInput, BasicVideo, Checkbox, ImageInput, SegmentButtons } from "components/common";
|
||||
import { AudioBlobPreview, AudioInput, BasicVideo, Checkbox, ImageInput, SegmentButtons } from "components/common";
|
||||
import { FaceAnimatorSlide } from "./FaceAnimatorTypes";
|
||||
|
||||
export default function FaceAnimatorInput({
|
||||
@@ -35,9 +35,17 @@ export default function FaceAnimatorInput({
|
||||
onRest: (p: any, c: any, item: any, l: any) => {
|
||||
toggle.audio(!!audioProps.file);
|
||||
},
|
||||
hideActions: true,
|
||||
// hideActions: true,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<AudioBlobPreview {...{
|
||||
...audioProps,
|
||||
onRest: (p: any, c: any, item: any, l: any) => {
|
||||
toggle.audio(!!audioProps.file);
|
||||
},
|
||||
hideActions: true,
|
||||
}}/>
|
||||
</AudioInput>
|
||||
<BasicVideo {...{ className: "face-animator-wide-sample", src: "/videos/face-animator-instruction-en.mp4" }}/>
|
||||
</div>
|
||||
<div {...{ className: "animation-configure-panel panel" }}>
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
faEye,
|
||||
faLanguage,
|
||||
faPencil,
|
||||
faWaveform,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { faEye, faLanguage, faPencil, faWaveform } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { usePrefixedDocumentTitle } from "common/UsePrefixedDocumentTitle";
|
||||
import Panel from "components/common/Panel";
|
||||
import { Stepper } from "./components/Stepper";
|
||||
@@ -15,16 +10,12 @@ import { VoiceDetails } from "./components/steps/VoiceDetails";
|
||||
import PageHeader from "components/layout/PageHeader";
|
||||
import Container from "components/common/Container";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { useFile } from "hooks";
|
||||
|
||||
import useVoiceRequests from "./useVoiceRequests";
|
||||
import useUploadedFiles from "hooks/useUploadedFiles";
|
||||
|
||||
import { FrontendInferenceJobType } from "@storyteller/components/src/jobs/InferenceJob";
|
||||
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
|
||||
import { useSession } from "hooks";
|
||||
|
||||
interface RouteParams {
|
||||
dataset_token?: string;
|
||||
@@ -37,8 +28,11 @@ function VoiceDesignerFormPage({ enqueueInferenceJob, sessionWrapper }: { enqueu
|
||||
const [visibility, visibilitySet] = useState("hidden");
|
||||
const [title, titleSet] = useState("");
|
||||
const [fetched,fetchedSet] = useState(false);
|
||||
const deleteEverything = useUploadedFiles((state: any) => state.deleteEverything);
|
||||
const audioProps = useFile({}); // contains upload inout state and controls, see docs
|
||||
const [deleting,deletingSet] = useState([]); // samples currently being uploaded
|
||||
const [inProgress,inProgressSet] = useState([]); // samples currently being uploaded
|
||||
const [samples,samplesSet] = useState([]); // fetched/uploaded samples
|
||||
const audioProps = useFile({});
|
||||
const { user, sessionFetched } = useSession();
|
||||
|
||||
const datasetInputs = [
|
||||
{
|
||||
@@ -67,28 +61,21 @@ function VoiceDesignerFormPage({ enqueueInferenceJob, sessionWrapper }: { enqueu
|
||||
];
|
||||
|
||||
const { dataset_token } = useParams<RouteParams>();
|
||||
const [isNewCreation] = useState(!dataset_token);
|
||||
const isEditMode = Boolean(dataset_token) && !isNewCreation;
|
||||
const existingVoice = !!dataset_token;
|
||||
|
||||
usePrefixedDocumentTitle(isEditMode ? "Edit Dataset" : "Create New Voice");
|
||||
usePrefixedDocumentTitle(existingVoice ? "Edit Dataset" : "Create New Voice");
|
||||
|
||||
const initialStep = history.location.pathname.includes("/upload") ? 1 : 0;
|
||||
const [currentStep, setCurrentStep] = useState(initialStep);
|
||||
const [audioSamplesReady, setAudioSamplesReady] = useState(false);
|
||||
const steps = existingVoice ? ["Edit Details", "Edit Samples"] : ["Voice Details", "Upload Samples"];
|
||||
const uploadProps = { audioProps, datasetToken: dataset_token, deleting, deletingSet, inProgress, inProgressSet, samples, samplesSet };
|
||||
|
||||
const steps = isEditMode
|
||||
? ["Edit Details", "Edit Samples"]
|
||||
: ["Voice Details", "Upload Samples"];
|
||||
|
||||
const key = !isNewCreation && (dataset_token || uuidv4());
|
||||
const displayStep = (step: any) => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return <VoiceDetails {...{ datasetInputs }} />;
|
||||
case 1:
|
||||
return (
|
||||
<UploadSamples key={key as any} {...{ audioProps, datasetToken: dataset_token, setAudioSamplesReady }} />
|
||||
);
|
||||
return <UploadSamples {...uploadProps }/>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -106,7 +93,7 @@ function VoiceDesignerFormPage({ enqueueInferenceJob, sessionWrapper }: { enqueu
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep === 0) {
|
||||
if (isNewCreation) {
|
||||
if (!existingVoice) {
|
||||
// It's a new creation and on the first step
|
||||
|
||||
datasets.create("",{
|
||||
@@ -115,7 +102,6 @@ function VoiceDesignerFormPage({ enqueueInferenceJob, sessionWrapper }: { enqueu
|
||||
idempotency_token: uuidv4(),
|
||||
}).then((res: any) => {
|
||||
if (res && res.success && res.token) {
|
||||
deleteEverything();
|
||||
history.push(`/voice-designer/dataset/${ res.token }/upload`);
|
||||
}
|
||||
});
|
||||
@@ -145,7 +131,6 @@ function VoiceDesignerFormPage({ enqueueInferenceJob, sessionWrapper }: { enqueu
|
||||
voice_dataset_token: dataset_token || "",
|
||||
})
|
||||
.then((res: any) => {
|
||||
deleteEverything();
|
||||
if (res && res.success) {
|
||||
enqueueInferenceJob(
|
||||
res.inference_job_token,
|
||||
@@ -169,17 +154,17 @@ function VoiceDesignerFormPage({ enqueueInferenceJob, sessionWrapper }: { enqueu
|
||||
}
|
||||
},[dataset_token,datasets,fetched]);
|
||||
|
||||
if (!sessionWrapper.isLoggedIn()) {
|
||||
if (sessionFetched && !user) {
|
||||
history.push("/voice-designer");
|
||||
}
|
||||
|
||||
return (
|
||||
<Container type="panel">
|
||||
<PageHeader
|
||||
title={isEditMode ? "Edit Dataset" : "Create New Voice"}
|
||||
titleIcon={isEditMode ? faPencil : faWaveform}
|
||||
title={existingVoice ? "Edit Dataset" : "Create New Voice"}
|
||||
titleIcon={existingVoice ? faPencil : faWaveform}
|
||||
subText={
|
||||
isEditMode
|
||||
existingVoice
|
||||
? "Edit your dataset by uploading more samples to create a new voice"
|
||||
: "Add voice details and upload audio samples to clone your voice!"
|
||||
}
|
||||
@@ -187,11 +172,10 @@ function VoiceDesignerFormPage({ enqueueInferenceJob, sessionWrapper }: { enqueu
|
||||
showBackButton={true}
|
||||
backbuttonLabel="Back to Voice Designer"
|
||||
backbuttonTo={
|
||||
isEditMode ? "/voice-designer/datasets" : "/voice-designer/voices"
|
||||
existingVoice ? "/voice-designer/datasets" : "/voice-designer/voices"
|
||||
}
|
||||
/>
|
||||
|
||||
<Panel>
|
||||
{ sessionFetched && user && <Panel>
|
||||
<div className="p-3 px-lg-4 bg-stepper">
|
||||
<Stepper steps={steps} currentStep={currentStep} />
|
||||
</div>
|
||||
@@ -204,9 +188,10 @@ function VoiceDesignerFormPage({ enqueueInferenceJob, sessionWrapper }: { enqueu
|
||||
onBack={handleBack}
|
||||
onNext={handleNext}
|
||||
onCreate={handleCreateVoice}
|
||||
createDisabled={!audioSamplesReady}
|
||||
createDisabled={ !!deleting.length || !!inProgress.length || !samples.length } // deleting, uploading, or no samples = disabled
|
||||
/>
|
||||
</Panel>
|
||||
}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,11 @@ import Container from "components/common/Container";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import ListItems from "./components/NewList";
|
||||
import Modal from "components/common/Modal";
|
||||
import { Button } from "components/common";
|
||||
import useVoiceRequests from "./useVoiceRequests";
|
||||
import { usePrefixedDocumentTitle } from "common/UsePrefixedDocumentTitle";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faMicrophone } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
import {
|
||||
FrontendInferenceJobType,
|
||||
@@ -24,7 +27,7 @@ import {
|
||||
} from "@storyteller/components/src/jobs/InferenceJob";
|
||||
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
|
||||
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
|
||||
import { Button } from "components/common";
|
||||
import { useSession } from "hooks";
|
||||
|
||||
interface Props {
|
||||
sessionWrapper: SessionWrapper;
|
||||
@@ -36,27 +39,21 @@ function VoiceDesignerMainPage(props: Props) {
|
||||
usePrefixedDocumentTitle("AI Voice Designer");
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useLocalize("FaceAnimator");
|
||||
const { datasets, voices, isFetching } = useVoiceRequests({
|
||||
requestDatasets: true,
|
||||
requestVoices: true,
|
||||
});
|
||||
const { datasets, voices, isLoading } = useVoiceRequests({ requestDatasets: true,requestVoices: true });
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const view = ["/voice-designer/datasets", "/voice-designer/voices"].indexOf(
|
||||
pathname
|
||||
);
|
||||
const view = ["/voice-designer/datasets", "/voice-designer/voices"].indexOf(pathname);
|
||||
const [deleteItem, setDeleteItem] = useState("");
|
||||
const [deleteType, setDeleteType] = useState("");
|
||||
const [deleteText, setDeleteText] = useState({
|
||||
title: "",
|
||||
text: "",
|
||||
});
|
||||
|
||||
const history = useHistory();
|
||||
const { user } = useSession();
|
||||
|
||||
if (pathname === "/voice-designer" || pathname === "/voice-designer/") {
|
||||
return <Redirect to="/voice-designer/voices" />;
|
||||
}
|
||||
|
||||
const openDeleteModal = (token: string, type: string) => {
|
||||
setDeleteItem(token);
|
||||
setDeleteType(type);
|
||||
@@ -87,26 +84,24 @@ function VoiceDesignerMainPage(props: Props) {
|
||||
history.push(`/voice-designer/voice/${token}`);
|
||||
};
|
||||
|
||||
const voiceClick =
|
||||
(todo: any, type: string) =>
|
||||
({ target }: { target: any }) => {
|
||||
let voiceToken =
|
||||
voices.list[target.name.split(",")[0].split(":")[1]].voice_token;
|
||||
todo(voiceToken, type);
|
||||
};
|
||||
const DataBadge = () => <span className="dataset-badge mb-0">Dataset</span>;
|
||||
const VoiceBadge = () => <FontAwesomeIcon icon={faMicrophone} className="me-2 me-lg-3" />;
|
||||
|
||||
const datasetClick =
|
||||
(todo: any, type: string) =>
|
||||
({ target }: { target: any }) => {
|
||||
let datasetToken =
|
||||
datasets.list[target.name.split(",")[0].split(":")[1]].dataset_token;
|
||||
todo(datasetToken, type);
|
||||
};
|
||||
// these need to be abstracted to use over again -V
|
||||
const voiceClick = (todo: any, type: string) => ({ target }: { target: any }) => {
|
||||
let voiceToken = voices.list[target.name.split(",")[0].split(":")[1]].voice_token;
|
||||
todo(voiceToken, type);
|
||||
};
|
||||
|
||||
const datasetClick = (todo: any, type: string) => ({ target }: { target: any }) => {
|
||||
let datasetToken = datasets.list[target.name.split(",")[0].split(":")[1]].dataset_token;
|
||||
todo(datasetToken, type);
|
||||
};
|
||||
|
||||
const actionDataSets = datasets.list.map((dataset, i) => {
|
||||
return {
|
||||
...dataset,
|
||||
badge: "dataset",
|
||||
badge: DataBadge,
|
||||
buttons: [
|
||||
{
|
||||
label: "Edit",
|
||||
@@ -128,7 +123,7 @@ function VoiceDesignerMainPage(props: Props) {
|
||||
const actionVoices = voices.list.map((voice, i) => {
|
||||
return {
|
||||
...voice,
|
||||
badge: "voice",
|
||||
badge: VoiceBadge,
|
||||
buttons: [
|
||||
{
|
||||
label: "Edit",
|
||||
@@ -180,122 +175,101 @@ function VoiceDesignerMainPage(props: Props) {
|
||||
to: "/pricing",
|
||||
};
|
||||
|
||||
const isLoggedIn = props.sessionWrapper.isLoggedIn();
|
||||
let pageHeader = <></>;
|
||||
if (!isLoggedIn) {
|
||||
pageHeader = (
|
||||
<PageHeader
|
||||
button={signUpButton}
|
||||
secondaryButton={pricingButton}
|
||||
title="Voice Designer"
|
||||
titleIcon={faWaveform}
|
||||
subText="Create your own AI voice by providing audio files of the voice you want to clone."
|
||||
panel={false}
|
||||
imageUrl="/images/header/voice-designer.png"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
pageHeader = (
|
||||
<PageHeader
|
||||
button={createVoiceButton}
|
||||
title="Voice Designer"
|
||||
titleIcon={faWaveform}
|
||||
subText="Create your own AI voice by providing audio files of the voice you want to clone."
|
||||
panel={false}
|
||||
imageUrl="/images/header/voice-designer.png"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const dataPlaceholder = () => <div className="d-flex flex-column list-items p-5 align-items-center">
|
||||
<h5 className="fw-semibold mb-3">
|
||||
You haven't created any voices.
|
||||
</h5>
|
||||
<Button
|
||||
icon={faPlus} // 1
|
||||
label="Create New Voice" // 2
|
||||
small={true}
|
||||
to="/voice-designer/create" // 3
|
||||
/>
|
||||
</div>;
|
||||
|
||||
let body = <></>;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
body = (
|
||||
<Panel padding={true}>
|
||||
<div className="d-flex flex-column align-items-center py-3 my-3 py-md-4 my-md-4 gap-4">
|
||||
<div className="text-center">
|
||||
<h4 className="fw-bold">Please log in to access voice creation.</h4>
|
||||
|
||||
<p className="text-center opacity-75">
|
||||
If you don't have an account yet, sign up now to unlock this
|
||||
feature!
|
||||
</p>
|
||||
const LogggedInView = () => <>
|
||||
<InferenceJobsList
|
||||
{...{
|
||||
t,
|
||||
inferenceJobs: props.inferenceJobsByCategory.get(
|
||||
FrontendInferenceJobType.VoiceDesignerCreateVoice
|
||||
),
|
||||
statusTxt,
|
||||
}}
|
||||
/>
|
||||
<Panel>
|
||||
<nav>
|
||||
<ul className="nav nav-tabs">
|
||||
<div className="d-flex flex-grow-1">
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
to="/voice-designer/voices"
|
||||
className="nav-link fs-6 px-3 px-lg-4"
|
||||
activeClassName="active"
|
||||
>
|
||||
My Voices
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
to="/voice-designer/datasets"
|
||||
className="nav-link fs-6"
|
||||
activeClassName="active"
|
||||
>
|
||||
My Datasets
|
||||
</NavLink>
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="d-flex gap-3 align-items-center">
|
||||
<Button
|
||||
label="Sign Up"
|
||||
variant="primary"
|
||||
icon={faPenToSquare}
|
||||
to="/signup"
|
||||
/>
|
||||
<Button
|
||||
label="Login"
|
||||
variant="secondary"
|
||||
icon={faRightToBracket}
|
||||
to="/login"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
<InferenceJobsList
|
||||
{...{
|
||||
t,
|
||||
inferenceJobs: props.inferenceJobsByCategory.get(
|
||||
FrontendInferenceJobType.VoiceDesignerCreateVoice
|
||||
),
|
||||
statusTxt,
|
||||
}}
|
||||
<div className="p-3 p-lg-4">
|
||||
<ListItems {...{ data: view ? actionVoices : actionDataSets, dataPlaceholder, isLoading }}/>
|
||||
</div>
|
||||
</Panel>
|
||||
</>;
|
||||
|
||||
const LoggedOutView = () => <Panel padding={true}>
|
||||
<div className="d-flex flex-column align-items-center py-3 my-3 py-md-4 my-md-4 gap-4">
|
||||
<div className="text-center">
|
||||
<h4 className="fw-bold">Please log in to access voice creation.</h4>
|
||||
|
||||
<p className="text-center opacity-75">
|
||||
If you don't have an account yet, sign up now to unlock this
|
||||
feature!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-3 align-items-center">
|
||||
<Button
|
||||
label="Sign Up"
|
||||
variant="primary"
|
||||
icon={faPenToSquare}
|
||||
to="/signup"
|
||||
/>
|
||||
<Panel mb={true}>
|
||||
<nav>
|
||||
<ul className="nav nav-tabs">
|
||||
<div className="d-flex flex-grow-1">
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
to="/voice-designer/voices"
|
||||
className="nav-link fs-6 px-3 px-lg-4"
|
||||
activeClassName="active"
|
||||
>
|
||||
My Voices
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
to="/voice-designer/datasets"
|
||||
className="nav-link fs-6"
|
||||
activeClassName="active"
|
||||
>
|
||||
My Datasets
|
||||
</NavLink>
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="p-3 p-lg-4">
|
||||
<ListItems
|
||||
{...{ data: view ? actionVoices : actionDataSets }}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
<Button
|
||||
label="Login"
|
||||
variant="secondary"
|
||||
icon={faRightToBracket}
|
||||
to="/login"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container type="panel">
|
||||
{pageHeader}
|
||||
|
||||
{body}
|
||||
</Container>
|
||||
|
||||
<PageHeader {...{
|
||||
button: user ? createVoiceButton : signUpButton,
|
||||
...!user ? { secondaryButton: pricingButton } : {},
|
||||
title: "Voice Designer",
|
||||
titleIcon: faWaveform,
|
||||
subText: "Create your own AI voice by providing audio files of the voice you want to clone.",
|
||||
panel: false,
|
||||
imageUrl: "/images/header/voice-designer.png"
|
||||
}}/>
|
||||
{ user ? <LogggedInView /> : <LoggedOutView /> }
|
||||
{/* Delete Modal */}
|
||||
<Modal
|
||||
show={isDeleteModalOpen}
|
||||
@@ -304,6 +278,7 @@ function VoiceDesignerMainPage(props: Props) {
|
||||
content={<p>{deleteText.text}</p>}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+4
@@ -27,3 +27,7 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.fy-vd-badge > * {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
@@ -1,124 +1,64 @@
|
||||
import React from "react";
|
||||
import Button from "components/common/Button";
|
||||
import { faMicrophone, faPlus } from "@fortawesome/pro-solid-svg-icons";
|
||||
import React from 'react';
|
||||
import Skeleton from "components/common/Skeleton";
|
||||
import { Button } from "components/common";
|
||||
|
||||
import "./ListItems/ListItems.scss";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import Skeleton from "components/common/Skeleton";
|
||||
|
||||
|
||||
interface ListItemsProps {
|
||||
data: any[];
|
||||
dataPlaceholder?: any;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface ListItem {
|
||||
badge: "voice" | "dataset";
|
||||
badge?: any;
|
||||
buttons: any;
|
||||
index: number;
|
||||
isCreating?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const Working = () => (
|
||||
<div className="d-flex align-items-center gap-2 py-1">
|
||||
<p className="fw-medium opacity-75">Voice is being created...</p>
|
||||
<div className="spinner-border spinner-border-sm text-light" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const LoaderView = () => <div className="list-items p-3">
|
||||
<h3 className="mb-0">
|
||||
<Skeleton type="medium" rounded />
|
||||
</h3>
|
||||
</div>;
|
||||
|
||||
const ItemRow = ({
|
||||
badge,
|
||||
buttons,
|
||||
index,
|
||||
isCreating,
|
||||
name,
|
||||
...rest
|
||||
}: ListItem) => {
|
||||
return (
|
||||
<div className="d-flex flex-column flex-lg-row gap-3 list-items p-3 align-items-lg-center">
|
||||
<div className="d-inline-flex flex-wrap align-items-center flex-grow-1">
|
||||
{badge === "dataset" ? (
|
||||
<div className="d-flex flex-column flex-lg-row align-items-start gap-2">
|
||||
<span className="dataset-badge mb-0">Dataset</span>
|
||||
<h5 className="fw-semibold mb-0">{name}</h5>
|
||||
</div>
|
||||
) : null}
|
||||
{badge === "voice" ? (
|
||||
<div className="d-inline-flex align-items-center">
|
||||
<h5 className="fw-semibold d-flex mb-0 align-items-start">
|
||||
<span>
|
||||
<FontAwesomeIcon icon={faMicrophone} className="me-2 me-lg-3" />
|
||||
</span>
|
||||
{name}
|
||||
</h5>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
{isCreating ? (
|
||||
<Working />
|
||||
) : (
|
||||
<div className="d-flex gap-2">
|
||||
{buttons && buttons.length
|
||||
? buttons.map((action: any, key: number) => {
|
||||
return (
|
||||
<Button
|
||||
{...{
|
||||
...action,
|
||||
key,
|
||||
name: `item-row:${index},button:${key}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
const ItemRow = ({ badge: Badge, buttons, index, isCreating, name, ...rest }: ListItem) => {
|
||||
return <div className="d-flex flex-column flex-lg-row gap-3 list-items p-3 align-items-lg-center">
|
||||
<div className="d-inline-flex flex-wrap align-items-center flex-grow-1 gap-2">
|
||||
<h5 className="fy-vd-badge fw-semibold mb-0">
|
||||
{ Badge ? <Badge /> : null }
|
||||
{ name }
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
<div className="d-flex">
|
||||
<div className="d-flex gap-2">
|
||||
{buttons && buttons.length
|
||||
? buttons.map((action: any, key: number) => {
|
||||
return (
|
||||
<Button
|
||||
{...{
|
||||
...action,
|
||||
key,
|
||||
name: `item-row:${index},button:${key}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default function NewList({ data, isLoading }: ListItemsProps) {
|
||||
const DataPlaceholder = () => (
|
||||
<div className="d-flex flex-column list-items p-5 align-items-center">
|
||||
<h5 className="fw-semibold mb-3">You haven't created any voices.</h5>
|
||||
<Button
|
||||
icon={faPlus} // 1
|
||||
label="Create New Voice" // 2
|
||||
small={true}
|
||||
to="/voice-designer/create" // 3
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export default function NewList({ data, dataPlaceholder, isLoading }: ListItemsProps) {
|
||||
const DataPlaceholder = dataPlaceholder || null;
|
||||
|
||||
if (isLoading) {
|
||||
const loaderView = (
|
||||
<div className="list-items p-3">
|
||||
<h3 className="mb-0">
|
||||
<Skeleton type="medium" rounded />
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column gap-3">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<React.Fragment key={i}>{loaderView}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return data.length ? (
|
||||
<div className="d-flex flex-column gap-3">
|
||||
{data.map((item, key) => (
|
||||
<ItemRow {...{ key, ...item, index: key }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<DataPlaceholder />
|
||||
);
|
||||
}
|
||||
return isLoading ? <LoaderView /> :
|
||||
data.length ? <div className="d-flex flex-column gap-3">
|
||||
{ data.map((item, key) => <ItemRow {...{ key, ...item, index: key }}/> ) }
|
||||
</div> :
|
||||
<DataPlaceholder />;
|
||||
};
|
||||
+103
-44
@@ -1,54 +1,113 @@
|
||||
import React, { useState } from "react";
|
||||
import VoiceDesignerUploadComponent from "../VoiceDesignerUploadComponent";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AudioInput } from "components/common";
|
||||
import moment from 'moment';
|
||||
import ListItems from "../NewList";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// import { SessionContext } from 'context';
|
||||
import { ListSamplesForDataset } from "@storyteller/components/src/api/voice_designer/voice_dataset_samples/ListSamplesForDataset";
|
||||
import { UploadSample } from "@storyteller/components/src/api/voice_designer/voice_dataset_samples/UploadSample";
|
||||
import { DeleteSample } from "@storyteller/components/src/api/voice_designer/voice_dataset_samples/DeleteSample";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faWaveform } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
function UploadSamples({
|
||||
audioProps,
|
||||
datasetToken,
|
||||
setAudioSamplesReady,
|
||||
}: {
|
||||
audioProps: any;
|
||||
datasetToken?: string;
|
||||
setAudioSamplesReady: any;
|
||||
}) {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
const [convertLoading, setConvertLoading] = useState(false);
|
||||
const [canConvert, setCanConvert] = useState(false);
|
||||
const [mediaUploadToken, setMediaUploadToken] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [convertIdempotencyToken, setConvertIdempotencyToken] = useState(
|
||||
uuidv4()
|
||||
);
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
interface Props {
|
||||
audioProps: any,
|
||||
datasetToken?: string,
|
||||
deleting: any,
|
||||
deletingSet: any,
|
||||
inProgress: any,
|
||||
inProgressSet: any
|
||||
samples: any,
|
||||
samplesSet: any
|
||||
}
|
||||
|
||||
const [formIsCleared, setFormIsCleared] = useState(false);
|
||||
function UploadSamples({ audioProps, datasetToken, deleting, deletingSet, inProgress, inProgressSet, samples, samplesSet }: Props) {
|
||||
const [listFetched,listFetchedSet] = useState(false);
|
||||
|
||||
const changeConvertIdempotencyToken = () => {
|
||||
setConvertIdempotencyToken(uuidv4());
|
||||
const SampleBadge = () => <FontAwesomeIcon icon={faWaveform} className="me-2 me-lg-3" />;
|
||||
|
||||
const sampleClick = () => ({ target }: { target: any }) => {
|
||||
let sampleToken = samples[target.name.split(",")[0].split(":")[1]].sample_token;
|
||||
deletingSet([ ...deleting, sampleToken ]); // add to deleting list
|
||||
DeleteSample(sampleToken,{ as_mod: false, set_delete: true })
|
||||
.then((res) => {
|
||||
listFetchedSet(false);
|
||||
deletingSet(deleting.filter((item: any) => item === sampleToken)); // remove from deleting list
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column gap-4">
|
||||
<div>
|
||||
<label className="sub-title">Upload Audio</label>
|
||||
<div className="d-flex flex-column gap-3 upload-component">
|
||||
<VoiceDesignerUploadComponent
|
||||
{...{
|
||||
changeConvertIdempotencyToken,
|
||||
datasetToken,
|
||||
formIsCleared,
|
||||
setCanConvert,
|
||||
setFormIsCleared,
|
||||
setMediaUploadToken,
|
||||
setAudioSamplesReady,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
const actionSamples = [ // these spread operators combine the inProgress and sample arrays
|
||||
...inProgress,
|
||||
...samples.map((sample: any, i: number) => {
|
||||
let date = new Date(sample.created_at);
|
||||
return {
|
||||
...sample,
|
||||
badge: SampleBadge,
|
||||
buttons: [
|
||||
{
|
||||
label: "Delete",
|
||||
small: true,
|
||||
variant: "secondary",
|
||||
onClick: sampleClick()
|
||||
},
|
||||
],
|
||||
name: `Sample from ${ date ? moment(date).format("MMMM Do YYYY, h:mm a") : "" }`,
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (audioProps.file && datasetToken) {
|
||||
let uuid_idempotency_token = uuidv4();
|
||||
audioProps.clear();
|
||||
inProgressSet([ // add sample to in progress list
|
||||
{
|
||||
// badge: Component // this can be a loading indicator
|
||||
name: "Uploading",
|
||||
uuid_idempotency_token
|
||||
},
|
||||
...inProgress
|
||||
]);
|
||||
UploadSample("",{
|
||||
dataset_token: datasetToken || "",
|
||||
file: audioProps.file,
|
||||
uuid_idempotency_token,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
inProgressSet(inProgress.filter((item: any) => // removes sample from in progress list
|
||||
item.uuid_idempotency_token === uuid_idempotency_token)
|
||||
);
|
||||
listFetchedSet(false); // refetches sample list
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (datasetToken && !listFetched) {
|
||||
listFetchedSet(true);
|
||||
ListSamplesForDataset(datasetToken,{})
|
||||
.then((res) => {
|
||||
if (res.success && res.samples) {
|
||||
samplesSet(res.samples);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
},[audioProps, datasetToken, listFetched, inProgress, inProgressSet, samplesSet]);
|
||||
|
||||
return <div className="d-flex flex-column gap-4">
|
||||
<label className="sub-title">Upload Audio</label>
|
||||
<div className="d-flex flex-column gap-3 upload-component">
|
||||
<AudioInput {...{ ...audioProps }}/>
|
||||
{ actionSamples.length ? <ListItems {...{ data: actionSamples, isLoading: false }}/> :
|
||||
<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={faWaveform} className="fs-3 mb-3" />
|
||||
<h5>No voice samples yet</h5>
|
||||
<p>Uploaded samples will appear here.</p>
|
||||
</div>
|
||||
</div> }
|
||||
</div>
|
||||
);
|
||||
</div>;
|
||||
}
|
||||
|
||||
export { UploadSamples };
|
||||
@@ -51,17 +51,18 @@ export default function useVoiceRequests({
|
||||
const [datasets, datasetsSet] = useState<Dataset[]>([]);
|
||||
const [voices, voicesSet] = useState<Voice[]>([]);
|
||||
|
||||
// this state stays here, fetched states are not success, merely an attempt was made. Each list fetch sets to true. Set to false to retry
|
||||
const [fetchDatasets, fetchDatasetsSet] = useState(requestDatasets);
|
||||
const [fetchVoices, fetchVoicesSet] = useState(requestVoices);
|
||||
const [isFetchingVoices, setIsFetchingVoices] = useState(requestVoices);
|
||||
const [isFetchingDatasets, setIsFetchingDatasets] = useState(requestDatasets);
|
||||
// 0 = paused, 1 = requested, 2 = started, 3 = success, we could even do 4 for errors
|
||||
const [datasetStatus, datasetStatusSet] = useState(requestDatasets ? 1 : 0);
|
||||
const [voicesStatus, voicesStatusSet] = useState(requestVoices ? 1 : 0);
|
||||
|
||||
const isBusy = (status: number) => status === 1 || status === 2;
|
||||
|
||||
const { user } = useSession();
|
||||
// const [timestamp, timestampSet] = useState(Date.now());
|
||||
|
||||
const refreshData = () => {
|
||||
fetchDatasetsSet(true);
|
||||
fetchVoicesSet(true);
|
||||
datasetStatusSet(1);
|
||||
voicesStatusSet(1);
|
||||
}; // later we can do refresh per list
|
||||
|
||||
const createDataset = (
|
||||
@@ -89,7 +90,6 @@ export default function useVoiceRequests({
|
||||
}).then((res) => {
|
||||
refreshData();
|
||||
return res;
|
||||
// console.log("🏧",res);
|
||||
});
|
||||
|
||||
const deleteDataset = (voiceToken: string): Promise<DeleteDatasetResponse> =>
|
||||
@@ -99,7 +99,6 @@ export default function useVoiceRequests({
|
||||
}).then((res) => {
|
||||
refreshData();
|
||||
return res;
|
||||
// console.log("🏧",res);
|
||||
});
|
||||
|
||||
const datasetByToken = (datasetToken?: string) =>
|
||||
@@ -111,7 +110,6 @@ export default function useVoiceRequests({
|
||||
datasetToken: string,
|
||||
request: UpdateDatasetRequest
|
||||
): Promise<UpdateDatasetResponse> => {
|
||||
// console.log("🍄", datasetToken);
|
||||
return UpdateDataset(datasetToken, request).then((res) => {
|
||||
refreshData();
|
||||
return res;
|
||||
@@ -119,7 +117,7 @@ export default function useVoiceRequests({
|
||||
};
|
||||
|
||||
const listDatasets = () => {
|
||||
fetchDatasetsSet(true);
|
||||
datasetStatusSet(1);
|
||||
return datasets;
|
||||
};
|
||||
|
||||
@@ -136,27 +134,24 @@ export default function useVoiceRequests({
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.username) {
|
||||
if (fetchDatasets) {
|
||||
setIsFetchingDatasets(true);
|
||||
fetchDatasetsSet(false);
|
||||
if (datasetStatus === 1) {
|
||||
datasetStatusSet(2);
|
||||
ListDatasetsByUser(user.username, {}).then((res) => {
|
||||
datasetStatusSet(3);
|
||||
if (res.datasets) datasetsSet(res.datasets);
|
||||
setIsFetchingDatasets(false);
|
||||
});
|
||||
}
|
||||
if (fetchVoices) {
|
||||
setIsFetchingVoices(true);
|
||||
fetchVoicesSet(false);
|
||||
if (voicesStatus === 1) {
|
||||
voicesStatusSet(2);
|
||||
ListVoicesByUser(user.username, {}).then((res) => {
|
||||
voicesStatusSet(3);
|
||||
if (res.voices) voicesSet(res.voices);
|
||||
setIsFetchingVoices(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [user, fetchDatasets, fetchVoices]);
|
||||
}, [user, datasetStatus, voicesStatus]);
|
||||
|
||||
return {
|
||||
isFetching: isFetchingVoices || isFetchingDatasets,
|
||||
datasets: {
|
||||
byToken: datasetByToken,
|
||||
create: createDataset,
|
||||
@@ -170,6 +165,7 @@ export default function useVoiceRequests({
|
||||
inference: {
|
||||
enqueue: EnqueueTts,
|
||||
},
|
||||
isLoading: isBusy(datasetStatus) || isBusy(voicesStatus),
|
||||
languages,
|
||||
visibilityOptions,
|
||||
voices: {
|
||||
@@ -182,9 +178,6 @@ export default function useVoiceRequests({
|
||||
},
|
||||
inputCtrl:
|
||||
(todo: any) =>
|
||||
({ target }: { target: any }) => {
|
||||
console.log("🌿", target);
|
||||
todo(target.value);
|
||||
},
|
||||
({ target }: { target: any }) => { todo(target.value); },
|
||||
};
|
||||
}
|
||||
@@ -4579,6 +4579,7 @@ __metadata:
|
||||
lerna: ^4.0.0
|
||||
prettier: ^3.1.0
|
||||
pretty-quick: ^3.1.3
|
||||
moment: ^2.29.4
|
||||
universal-cookie: ^6.1.1
|
||||
yarn: ^1.22.19
|
||||
languageName: unknown
|
||||
@@ -14849,6 +14850,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"moment@npm:^2.29.4":
|
||||
version: 2.29.4
|
||||
resolution: "moment@npm:2.29.4"
|
||||
checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"move-concurrently@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "move-concurrently@npm:1.0.1"
|
||||
|
||||
Reference in new issue
Block a user