|
|
<!DOCTYPE html> |
|
|
<html lang="fa" dir="rtl"> |
|
|
<head> |
|
|
<meta charset="UTF-8" /> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
|
<title>تغییر صدا با هوش مصنوعی آلفا</title> |
|
|
|
|
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
<script> |
|
|
tailwind.config = { |
|
|
theme: { |
|
|
extend: { |
|
|
colors: { |
|
|
primary: '#667eea', |
|
|
secondary: '#764ba2', |
|
|
accent: '#38b2ac', |
|
|
}, |
|
|
fontFamily: { |
|
|
vazir: ['Vazirmatn', 'sans-serif'], |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
</script> |
|
|
|
|
|
|
|
|
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;300;400;500;700;900&display=swap" rel="stylesheet"> |
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"> |
|
|
|
|
|
|
|
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> |
|
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> |
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> |
|
|
|
|
|
<style> |
|
|
body { |
|
|
font-family: 'Vazirmatn', sans-serif; |
|
|
background: #fdfbfb; |
|
|
background-image: radial-gradient(#e0e0e0 1px, transparent 1px); |
|
|
background-size: 30px 30px; |
|
|
} |
|
|
|
|
|
::-webkit-scrollbar { |
|
|
width: 8px; |
|
|
} |
|
|
::-webkit-scrollbar-track { |
|
|
background: #f1f1f1; |
|
|
} |
|
|
::-webkit-scrollbar-thumb { |
|
|
background: #c7c7c7; |
|
|
border-radius: 4px; |
|
|
} |
|
|
::-webkit-scrollbar-thumb:hover { |
|
|
background: #a8a8a8; |
|
|
} |
|
|
.no-scrollbar::-webkit-scrollbar { |
|
|
display: none; |
|
|
} |
|
|
.no-scrollbar { |
|
|
-ms-overflow-style: none; |
|
|
scrollbar-width: none; |
|
|
} |
|
|
|
|
|
@keyframes scaleIn { |
|
|
from { opacity: 0; transform: scale(0.8) translateY(20px); } |
|
|
to { opacity: 1; transform: scale(1) translateY(0); } |
|
|
} |
|
|
@keyframes fadeIn { |
|
|
from { opacity: 0; } |
|
|
to { opacity: 1; } |
|
|
} |
|
|
@keyframes fadeOut { |
|
|
from { opacity: 1; } |
|
|
to { opacity: 0; } |
|
|
} |
|
|
@keyframes shimmer { |
|
|
100% { transform: translateX(100%); } |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="root"></div> |
|
|
|
|
|
<script type="text/babel" data-presets="env,react,typescript"> |
|
|
const { useState, useEffect, useRef } = React; |
|
|
|
|
|
|
|
|
const MODELS = [ |
|
|
|
|
|
{ |
|
|
id: 'shadmehr', |
|
|
name: 'شادمهر عقیلی', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188203.jpg?_t=1725334498', |
|
|
refFile: '/refs/shadmehr.wav', |
|
|
category: 'singers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/amirmatrix/shadmehr/resolve/main/added_IVF722_Flat_nprobe_1_shadmehr_v2.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/55c918_25شادمهر-قوی-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'moein', |
|
|
name: 'معین', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/5dbc55de-d6ab-442f-9a00-da874521cc0b.jpg?_t=1725334795', |
|
|
refFile: '/refs/moein.wav', |
|
|
category: 'singers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Moein.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/f8bb17_25معین-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'sorena', |
|
|
name: 'علی سورنا', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/32eda534-2dfc-4053-8248-6fafea195a54.jpg?_t=1726904951', |
|
|
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D9%85%D8%AF%D9%84%20%D8%B5%D8%AF%D8%A7%DB%8C%20%D8%B3%D9%88%D8%B1%D9%86%D8%A7.mp3?download=true', |
|
|
category: 'singers', |
|
|
type: 'standard', |
|
|
targetGender: 'male', |
|
|
sampleAudio: "https://uploadkon.ir/uploads/659018_25%D8%B3%D9%88%D8%B1%D9%86%D8%A7-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'billie', |
|
|
name: 'بیلی آیلیش', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1551c598-f02f-4ced-a037-33d2d7317edd.jpg?_t=1726723022', |
|
|
refFile: '/refs/billie.wav', |
|
|
category: 'singers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'female', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Billie.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/c21018_25بیلی-آیلیش-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'chavoshi', |
|
|
name: 'محسن چاوشی', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/c52eefb1-071e-40ea-9bc2-e20a7c29cb81.jpg?_t=1726907812', |
|
|
refFile: '/refs/chavoshi.wav', |
|
|
category: 'singers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/chavoshi250.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/7ca518_25محسن-چاووشی-3-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'ghomayshi', |
|
|
name: 'سیاوش قمیشی', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000189540%20(1).jpg?_t=1726726690', |
|
|
refFile: '/refs/ghomayshi.wav', |
|
|
category: 'singers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Ghomayshi250.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/787e17_25قمیشی-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'yas', |
|
|
name: 'یاس', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/c942a66b-55e9-4987-8554-26ea572627e3.jpg?_t=1726905661', |
|
|
refFile: '/refs/yas.wav', |
|
|
category: 'singers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Yas300.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/2a2617_25یاس-2-.mp3" |
|
|
}, |
|
|
|
|
|
{ |
|
|
id: 'ferdosipour', |
|
|
name: 'عادل فردوسیپور', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188207.jpg?_t=1725334637', |
|
|
refFile: '/refs/ferdosipour.wav', |
|
|
category: 'dubbers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Adel.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/b5f918_25-عادل-فردوسی-پور-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'khiabani', |
|
|
name: 'جواد خیابانی', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000189552.jpg?_t=1726729222', |
|
|
refFile: '/refs/khiabani.wav', |
|
|
category: 'dubbers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Khiabani250.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/b7b118_25خیابانی-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'jalilvand', |
|
|
name: 'چنگیز جلیلوند', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/bf38e73f-19c8-44e7-bf2f-e53b9fb8f6a1.jpg?_t=1726723782', |
|
|
refFile: '/refs/jalilvand.wav', |
|
|
category: 'dubbers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/ChangizJalilvand.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/520514_25%D8%AC%D9%84%DB%8C%D9%84%D9%88%D9%86%D8%AF-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'tahmasb', |
|
|
name: 'ناصر طهماسب', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188502.jpg?_t=1725942876', |
|
|
refFile: '/refs/tahmasb.wav', |
|
|
category: 'dubbers', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/NasserTahmasb350.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/932a18_25-ناصر-طهماسب-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'nasr', |
|
|
name: 'تورج نصر', |
|
|
image: 'https://uploadkon.ir/uploads/f4f220_25۲۰۲۵۱۰۲۰-۱۱۰۷۰۵.jpg', |
|
|
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%AA%D9%88%D8%B1%D8%AC%20%D9%86%D8%B5%D8%B1.mp3?download=true', |
|
|
category: 'dubbers', |
|
|
type: 'standard', |
|
|
targetGender: 'male', |
|
|
sampleAudio: "https://uploadkon.ir/uploads/4c8018_25-تورج-نصر-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'mozafari', |
|
|
name: 'سعید مظفری', |
|
|
image: 'https://uploadkon.ir/uploads/830a19_25IMG-20251219-102026-776.jpg', |
|
|
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/saeid-mozafari.mp3?download=true', |
|
|
category: 'dubbers', |
|
|
type: 'standard', |
|
|
targetGender: 'male', |
|
|
sampleAudio: "https://uploadkon.ir/uploads/ac7019_25%D8%B3%D8%B9%DB%8C%D8%AF-%D9%85%D8%B8%D9%81%D8%B1%DB%8C-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'valizadeh', |
|
|
name: 'منوچهر والی زاده', |
|
|
image: 'https://uploadkon.ir/uploads/654119_25IMG-20251219-115557-637.jpg', |
|
|
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%B5%D8%AF%D8%A7%DB%8C%20%D9%85%D8%AF%D9%84%20%D9%85%D9%86%D9%88%DA%86%D9%87%D8%B1%20%D9%88%D8%A7%D9%84%DB%8C%E2%80%8C%D8%B2%D8%A7%D8%AF%D9%87.wav?download=true', |
|
|
category: 'dubbers', |
|
|
type: 'standard', |
|
|
targetGender: 'male', |
|
|
sampleAudio: "https://uploadkon.ir/uploads/549019_25%D9%85%D9%86%D9%88%DA%86%D9%87%D8%B1-%D9%88%D8%A7%D9%84%DB%8C-%D8%B2%D8%A7%D8%AF%D9%87-2-.mp3" |
|
|
}, |
|
|
|
|
|
{ |
|
|
id: 'pourang', |
|
|
name: 'عمو پورنگ', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/64a802d3-a50d-45a7-8ba2-0262b9d7bdcd.jpg?_t=1725948260', |
|
|
refFile: '/refs/pourang.wav', |
|
|
category: 'cartoons', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Poorang220.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/36bf18_25عمو-پورنگ-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'sponge', |
|
|
name: 'باب اسفنجی', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188654.jpg?_t=1725975676', |
|
|
refFile: '/refs/sponge.wav', |
|
|
category: 'cartoons', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Bab_Asfanj300.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/6f9118_25باب-اسفنجی-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'jenab', |
|
|
name: 'جناب خان', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/86501f6b-a29a-48ef-98ef-b97289de1a65.jpg?_t=1726728539', |
|
|
refFile: '/refs/jenab.wav', |
|
|
category: 'cartoons', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Jenab.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/d50a18_25جناب-خان-2-.mp3" |
|
|
}, |
|
|
|
|
|
{ |
|
|
id: 'yousef', |
|
|
name: 'یوسف پیامبر', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188210.jpg?_t=1725334745', |
|
|
refFile: '/refs/yousef.wav', |
|
|
category: 'famous', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Yusuf250.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/18a618_25یوسف-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'jomeong', |
|
|
name: 'جومونگ', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188512.jpg?_t=1725946678', |
|
|
refFile: '/refs/jomeong.wav', |
|
|
category: 'famous', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Jumong250.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/2a4918_25جومونگ-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'ronaldo', |
|
|
name: 'رونالدو', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188814.jpg?_t=1726224287', |
|
|
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%B1%D9%88%D9%86%D8%A7%D9%84%D8%AF%D9%88%20%D8%AE%D9%88%D8%A8_02.wav?download=true', |
|
|
category: 'famous', |
|
|
type: 'standard', |
|
|
targetGender: 'male', |
|
|
sampleAudio: "https://uploadkon.ir/uploads/084318_25رونالدو-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'messi', |
|
|
name: 'مسی', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000189556.jpg?_t=1726730420', |
|
|
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%Bص%D8%AF%D8%A7%DB%8C%20%D9%85%D8%B3%DB%8C.mp3?download=true', |
|
|
category: 'famous', |
|
|
type: 'standard', |
|
|
targetGender: 'male', |
|
|
sampleAudio: "https://uploadkon.ir/uploads/1f9b18_25مسی-2-.mp3" |
|
|
}, |
|
|
{ |
|
|
id: 'musk', |
|
|
name: 'ایلان ماسک', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/7ba0101a-83ed-46c3-a2e7-57a85e034b2c.jpg', |
|
|
refFile: '/refs/musk.wav', |
|
|
category: 'famous', |
|
|
type: 'legacy_rvc', |
|
|
targetGender: 'male', |
|
|
legacyConfig: { |
|
|
modelUrl: "https://huggingface.co/rayzox57/ElonMusk_90s_RVC/resolve/main/ElonMusk_90s_v2_Ov2_500e.zip?download=true" |
|
|
}, |
|
|
sampleAudio: "https://uploadkon.ir/uploads/e27318_25ایلان-ماسک-2-.mp3" |
|
|
}, |
|
|
|
|
|
{ |
|
|
id: 'maryam', |
|
|
name: 'مریم', |
|
|
image: 'https://app.puzzley.net/uploads/user/Jydo/%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/ab1e28b106df4c48ac228da6ced9d076.jpeg', |
|
|
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%B5%D8%AF%D8%A7%DB%8C%20%D8%AE%D8%A7%D9%86%D9%85.wav?download=true', |
|
|
category: 'alpha', |
|
|
type: 'standard', |
|
|
targetGender: 'female', |
|
|
sampleAudio: "https://uploadkon.ir/uploads/67b718_25%D9%85%D8%B1%DB%8C%D9%85.mp3" |
|
|
} |
|
|
]; |
|
|
|
|
|
|
|
|
const DB_NAME = 'SadaAI_DB'; |
|
|
const STORE_JOBS = 'jobs'; |
|
|
const STORE_CUSTOM_MODELS = 'custom_models'; |
|
|
const DB_VERSION = 2; |
|
|
|
|
|
const initDB = () => { |
|
|
return new Promise((resolve, reject) => { |
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION); |
|
|
request.onerror = () => reject(request.error); |
|
|
request.onsuccess = () => resolve(request.result); |
|
|
request.onupgradeneeded = (event) => { |
|
|
const db = event.target.result; |
|
|
if (!db.objectStoreNames.contains(STORE_JOBS)) { |
|
|
db.createObjectStore(STORE_JOBS, { keyPath: 'job_id' }); |
|
|
} |
|
|
if (!db.objectStoreNames.contains(STORE_CUSTOM_MODELS)) { |
|
|
db.createObjectStore(STORE_CUSTOM_MODELS, { keyPath: 'id' }); |
|
|
} |
|
|
}; |
|
|
}); |
|
|
}; |
|
|
|
|
|
const saveJob = async (job) => { |
|
|
const db = await initDB(); |
|
|
const transaction = db.transaction(STORE_JOBS, 'readwrite'); |
|
|
const store = transaction.objectStore(STORE_JOBS); |
|
|
await store.put(job); |
|
|
}; |
|
|
|
|
|
const getAllJobs = async () => { |
|
|
const db = await initDB(); |
|
|
const transaction = db.transaction(STORE_JOBS, 'readonly'); |
|
|
const store = transaction.objectStore(STORE_JOBS); |
|
|
const request = store.getAll(); |
|
|
return new Promise((resolve) => { |
|
|
request.onsuccess = () => resolve(request.result || []); |
|
|
}); |
|
|
}; |
|
|
|
|
|
const deleteJob = async (jobId) => { |
|
|
const db = await initDB(); |
|
|
const transaction = db.transaction(STORE_JOBS, 'readwrite'); |
|
|
const store = transaction.objectStore(STORE_JOBS); |
|
|
await store.delete(jobId); |
|
|
}; |
|
|
|
|
|
const clearAllJobs = async () => { |
|
|
const db = await initDB(); |
|
|
const transaction = db.transaction(STORE_JOBS, 'readwrite'); |
|
|
const store = transaction.objectStore(STORE_JOBS); |
|
|
await store.clear(); |
|
|
}; |
|
|
|
|
|
const saveCustomModel = async (model) => { |
|
|
const db = await initDB(); |
|
|
const transaction = db.transaction(STORE_CUSTOM_MODELS, 'readwrite'); |
|
|
const store = transaction.objectStore(STORE_CUSTOM_MODELS); |
|
|
await store.put(model); |
|
|
}; |
|
|
|
|
|
const getAllCustomModels = async () => { |
|
|
const db = await initDB(); |
|
|
const transaction = db.transaction(STORE_CUSTOM_MODELS, 'readonly'); |
|
|
const store = transaction.objectStore(STORE_CUSTOM_MODELS); |
|
|
const request = store.getAll(); |
|
|
return new Promise((resolve) => { |
|
|
request.onsuccess = () => resolve(request.result || []); |
|
|
}); |
|
|
}; |
|
|
|
|
|
const deleteCustomModel = async (id) => { |
|
|
const db = await initDB(); |
|
|
const transaction = db.transaction(STORE_CUSTOM_MODELS, 'readwrite'); |
|
|
const store = transaction.objectStore(STORE_CUSTOM_MODELS); |
|
|
await store.delete(id); |
|
|
}; |
|
|
|
|
|
|
|
|
const getBrowserFingerprint = async () => { |
|
|
const components = [ |
|
|
navigator.userAgent, |
|
|
navigator.language, |
|
|
screen.width + 'x' + screen.height, |
|
|
new Date().getTimezoneOffset() |
|
|
]; |
|
|
const str = components.join('---'); |
|
|
let hash = 0; |
|
|
for (let i = 0; i < str.length; i++) { |
|
|
hash = ((hash << 5) - hash) + str.charCodeAt(i); |
|
|
hash |= 0; |
|
|
} |
|
|
return 'fp_' + Math.abs(hash).toString(16); |
|
|
}; |
|
|
|
|
|
const checkCredit = async (fingerprint, modelId) => { |
|
|
|
|
|
return { limit_reached: false }; |
|
|
}; |
|
|
|
|
|
const useCredit = async (fingerprint, modelId) => { |
|
|
|
|
|
return {}; |
|
|
}; |
|
|
|
|
|
|
|
|
const LEGACY_BASE_URL = 'https://ezmary-rvc-zero.hf.space'; |
|
|
|
|
|
const uploadLegacyJob = async ( |
|
|
sourceFile, modelUrl, pitch, modelMetadata, algo = "rmvpe+", indexInf = 0.75 |
|
|
) => { |
|
|
const formData = new FormData(); |
|
|
formData.append('audio_file', sourceFile, sourceFile.name || 'input.wav'); |
|
|
formData.append('model_url', modelUrl); |
|
|
formData.append('pitch', pitch.toString()); |
|
|
formData.append('algo', algo); |
|
|
formData.append('index_inf', indexInf.toString()); |
|
|
formData.append('res_filter', "3"); |
|
|
formData.append('env_ratio', "0.25"); |
|
|
formData.append('protect', "0.33"); |
|
|
formData.append('denoise', "false"); |
|
|
formData.append('reverb', "false"); |
|
|
|
|
|
const response = await fetch(`${LEGACY_BASE_URL}/upload`, { |
|
|
method: 'POST', |
|
|
body: formData, |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errData = await response.json().catch(() => ({})); |
|
|
throw new Error(errData.error || `خطا در اتصال به سرور لگاسی: ${response.status}`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
return { |
|
|
job_id: data.job_id, |
|
|
status: 'started', |
|
|
progress: 10, |
|
|
statusMessage: 'در صف انتظار سرور (Persistent)...', |
|
|
total_chunks: 1, |
|
|
chunks: [], |
|
|
date: new Date().toLocaleString('fa-IR'), |
|
|
timestamp: Date.now(), |
|
|
type: 'model', |
|
|
modelName: modelMetadata.name, |
|
|
modelImage: modelMetadata.image, |
|
|
retryCount: 0, |
|
|
backend: 'legacy' |
|
|
}; |
|
|
}; |
|
|
|
|
|
const checkLegacyStatus = async (jobId) => { |
|
|
try { |
|
|
const response = await fetch(`${LEGACY_BASE_URL}/status/${jobId}`); |
|
|
if (!response.ok) throw new Error(`Legacy status check failed: ${response.status}`); |
|
|
const data = await response.json(); |
|
|
|
|
|
let status = 'processing'; |
|
|
if (data.status === 'completed') status = 'completed'; |
|
|
else if (data.status === 'failed') status = 'failed'; |
|
|
else if (data.status === 'not_found') status = 'error'; |
|
|
|
|
|
return { |
|
|
status, |
|
|
progress: data.status === 'completed' ? 100 : (data.status === 'processing' ? 60 : 10), |
|
|
filename: data.filename, |
|
|
detail: data.log, |
|
|
downloadUrl: data.filename ? `${LEGACY_BASE_URL}/download/${data.filename}` : undefined |
|
|
}; |
|
|
} catch (e) { |
|
|
return { status: 'processing', progress: 10, detail: 'درحال تلاش برای برقراری ارتباط با سرور...' }; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const VC_BASE_URL = 'https://ezmary-sada.hf.space'; |
|
|
const TTS_BASE_URL = 'https://hamed744-ttsp3.hf.space'; |
|
|
const ENHANCE_BASE_URL = 'https://ezmary-taqviat-sada.hf.space'; |
|
|
|
|
|
const uploadJob = async (sourceFile, refFile, metadata) => { |
|
|
const formData = new FormData(); |
|
|
formData.append('source_audio', sourceFile, 'source.wav'); |
|
|
formData.append('ref_audio', refFile, 'ref.wav'); |
|
|
|
|
|
const response = await fetch(`${VC_BASE_URL}/upload`, { |
|
|
method: 'POST', |
|
|
body: formData, |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error(`Upload failed: ${response.status}`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
return { |
|
|
...data, |
|
|
date: new Date().toLocaleString('fa-IR'), |
|
|
timestamp: Date.now(), |
|
|
progress: 0, |
|
|
status: 'started', |
|
|
type: metadata.type, |
|
|
modelName: metadata.modelName, |
|
|
modelImage: metadata.modelImage, |
|
|
backend: 'standard' |
|
|
}; |
|
|
}; |
|
|
|
|
|
const uploadEnhancementJob = async (audioBlob) => { |
|
|
const formData = new FormData(); |
|
|
formData.append('source_audio', audioBlob, 'input_to_enhance.wav'); |
|
|
formData.append('solver', 'Midpoint'); |
|
|
formData.append('nfe', '64'); |
|
|
formData.append('tau', '0.5'); |
|
|
formData.append('denoising', 'true'); |
|
|
formData.append('subscriptionStatus', 'paid'); |
|
|
formData.append('fingerprint', 'admin_bypass'); |
|
|
|
|
|
const response = await fetch(`${ENHANCE_BASE_URL}/upload`, { |
|
|
method: 'POST', |
|
|
body: formData, |
|
|
}); |
|
|
|
|
|
return await response.json(); |
|
|
}; |
|
|
|
|
|
const checkEnhancementStatus = async (metadata) => { |
|
|
const response = await fetch(`${ENHANCE_BASE_URL}/check_status`, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ |
|
|
job_id: metadata.job_id, |
|
|
total_chunks: metadata.total_chunks, |
|
|
chunks: metadata.chunks |
|
|
}), |
|
|
}); |
|
|
return await response.json(); |
|
|
}; |
|
|
|
|
|
const getEnhancementDownloadUrl = (filename) => { |
|
|
return `${ENHANCE_BASE_URL}/download/${filename}`; |
|
|
}; |
|
|
|
|
|
const generateBaseTTS = async (text, speakerId = "Algieba") => { |
|
|
const response = await fetch(`${TTS_BASE_URL}/api/generate`, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ text, speaker: speakerId, temperature: 0.9, subscriptionStatus: 'paid', fingerprint: 'admin_bypass' }) |
|
|
}); |
|
|
if (!response.ok) throw new Error(`TTS Generation failed`); |
|
|
const data = await response.json(); |
|
|
const jobId = data.job_id; |
|
|
|
|
|
return new Promise((resolve, reject) => { |
|
|
const pollInterval = setInterval(async () => { |
|
|
try { |
|
|
const statusRes = await fetch(`${TTS_BASE_URL}/api/check_status`, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ job_id: jobId }) |
|
|
}); |
|
|
const statusData = await statusRes.json(); |
|
|
if (statusData.status === 'completed' && statusData.proxy_url) { |
|
|
clearInterval(pollInterval); |
|
|
const audioRes = await fetch(`${TTS_BASE_URL}${statusData.proxy_url}`); |
|
|
resolve(await audioRes.blob()); |
|
|
} else if (statusData.status === 'failed') { |
|
|
clearInterval(pollInterval); |
|
|
reject(new Error("TTS failed")); |
|
|
} |
|
|
} catch (e) { clearInterval(pollInterval); reject(e); } |
|
|
}, 3000); |
|
|
}); |
|
|
}; |
|
|
|
|
|
const fetchRefAudioAsFile = async (url, filename) => { |
|
|
const response = await fetch(url); |
|
|
const blob = await response.blob(); |
|
|
return new File([blob], filename, { type: 'audio/wav' }); |
|
|
}; |
|
|
|
|
|
const checkJobStatus = async (job) => { |
|
|
if (job.backend === 'legacy') return await checkLegacyStatus(job.job_id); |
|
|
if (job.backend === 'standard') { |
|
|
const response = await fetch(`${VC_BASE_URL}/check_status`, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ job_id: job.job_id, total_chunks: job.total_chunks || 1, chunks: job.chunks || [] }), |
|
|
}); |
|
|
return await response.json(); |
|
|
} |
|
|
|
|
|
const legacyNames = [ |
|
|
'shadmehr', 'moein', 'billie', 'chavoshi', 'ghomayshi', 'yas', 'ferdosipour', 'khiabani', 'jalilvand', 'tahmasb', 'pourang', 'sponge', 'jenab', 'yousef', 'jomeong', 'musk', |
|
|
'شادمهر عقیلی', 'معین', 'بیلی آیلیش', 'محسن چاوشی', 'سیاوش قمیشی', 'یاس', |
|
|
'عادل فردوسیپور', 'جواد خیابانی', 'چنگیز جلیلوند', 'ناصر طهماسب', |
|
|
'عمو پورنگ', 'باب اسفنجی', 'جناب خان', |
|
|
'یوسف پیامبر', 'جومونگ', 'ایلان ماسک' |
|
|
]; |
|
|
|
|
|
const isLegacy = (job.modelName && legacyNames.includes(job.modelName.toLowerCase())) || job.job_id.includes('-'); |
|
|
|
|
|
if (isLegacy) return await checkLegacyStatus(job.job_id); |
|
|
|
|
|
const response = await fetch(`${VC_BASE_URL}/check_status`, { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
body: JSON.stringify({ job_id: job.job_id, total_chunks: job.total_chunks || 1, chunks: job.chunks || [] }), |
|
|
}); |
|
|
return await response.json(); |
|
|
}; |
|
|
|
|
|
const processUnifiedJob = async ( |
|
|
sourceFile, text, refFile, model, mode, pitch, onProgress, jobType |
|
|
) => { |
|
|
let actualSource; |
|
|
if (mode === 'vc') { |
|
|
if (!sourceFile) throw new Error("فایل صوتی یافت نشد"); |
|
|
actualSource = sourceFile; |
|
|
} else { |
|
|
if (!text) throw new Error("متن یافت نشد"); |
|
|
let baseSpeaker = (model && model.targetGender === 'female') ? "Despina" : "Algieba"; |
|
|
onProgress(`تولید صدای پایه...`, 5); |
|
|
actualSource = await generateBaseTTS(text, baseSpeaker); |
|
|
onProgress("صدای پایه تولید شد.", 10); |
|
|
} |
|
|
|
|
|
const metadata = { type: jobType, modelName: model?.name, modelImage: typeof model?.image === 'string' ? model.image : undefined }; |
|
|
|
|
|
if (model && model.type === 'legacy_rvc' && model.legacyConfig) { |
|
|
onProgress("ارسال به سرور لگاسی...", 15); |
|
|
return await uploadLegacyJob(actualSource, model.legacyConfig.modelUrl, pitch, { name: model.name, image: metadata.modelImage }); |
|
|
} else { |
|
|
onProgress("ارسال به سرور تغییر صدا...", 20); |
|
|
return await uploadJob(actualSource, refFile, metadata); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const ProcessingPanel = ({ job, embedded = false, onEnhance }) => { |
|
|
const [downloadingType, setDownloadingType] = useState(null); |
|
|
const [copySuccess, setCopySuccess] = useState(false); |
|
|
|
|
|
if (!job) return null; |
|
|
|
|
|
const isCompleted = job.status === 'completed'; |
|
|
const isFailed = job.status === 'failed' || job.status === 'error'; |
|
|
const VC_BASE_URL = 'https://ezmary-sada.hf.space'; |
|
|
let downloadUrl = '#'; |
|
|
if (isCompleted) { |
|
|
if (job.downloadUrl) { |
|
|
downloadUrl = job.downloadUrl; |
|
|
} else if (job.filename) { |
|
|
downloadUrl = `${VC_BASE_URL}/download/${job.filename}`; |
|
|
} |
|
|
} |
|
|
|
|
|
const enhanceInfo = job.enhancement; |
|
|
const isEnhancing = enhanceInfo?.status === 'processing' || enhanceInfo?.status === 'started'; |
|
|
const isEnhanceCompleted = enhanceInfo?.status === 'completed'; |
|
|
const enhanceDownloadUrl = isEnhanceCompleted && enhanceInfo?.filename |
|
|
? getEnhancementDownloadUrl(enhanceInfo.filename) |
|
|
: '#'; |
|
|
|
|
|
const handleDownload = async (e, url, type) => { |
|
|
e.preventDefault(); |
|
|
if (downloadingType) return; |
|
|
setDownloadingType(type); |
|
|
try { |
|
|
let blobUrl = url; |
|
|
if (!url.startsWith('blob:')) { |
|
|
const resp = await fetch(url); |
|
|
if (!resp.ok) throw new Error(`Network response was not ok`); |
|
|
const blob = await resp.blob(); |
|
|
blobUrl = URL.createObjectURL(blob); |
|
|
} |
|
|
|
|
|
|
|
|
window.parent.postMessage({ |
|
|
type: 'INITIATE_DOWNLOAD_FROM_URL', |
|
|
payload: { audioUrl: blobUrl } |
|
|
}, '*'); |
|
|
|
|
|
|
|
|
const a = document.createElement('a'); |
|
|
a.href = blobUrl; |
|
|
a.download = type === 'main' ? (job.filename || 'audio.wav') : `enhanced_${job.filename || 'audio.wav'}`; |
|
|
document.body.appendChild(a); |
|
|
a.click(); |
|
|
document.body.removeChild(a); |
|
|
} catch (err) { |
|
|
console.error("Download error:", err); |
|
|
alert("خطا در دانلود"); |
|
|
} finally { |
|
|
setDownloadingType(null); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleCopyErrorLog = () => { |
|
|
const logText = job.statusMessage || 'Unknown Error'; |
|
|
navigator.clipboard.writeText(logText).then(() => { |
|
|
setCopySuccess(true); |
|
|
setTimeout(() => setCopySuccess(false), 2000); |
|
|
}).catch(err => console.error('Failed to copy', err)); |
|
|
}; |
|
|
|
|
|
const containerClasses = embedded |
|
|
? "bg-gray-50/80 rounded-lg p-3 mt-2 border border-gray-100 animate-[fadeIn_0.3s_ease-out]" |
|
|
: "bg-white rounded-3xl shadow-lg border border-gray-100 p-6 mt-6 animate-[slideUp_0.5s_ease-out]"; |
|
|
|
|
|
return ( |
|
|
<div className={containerClasses}> |
|
|
<div className="flex items-center justify-between mb-2"> |
|
|
<div> |
|
|
<h4 className={`font-bold text-gray-800 ${embedded ? 'text-xs' : 'text-lg'}`}> |
|
|
{isCompleted ? 'فایل اصلی آماده' : isFailed ? 'خطا در پردازش' : 'در حال تغییر صدا...'} |
|
|
</h4> |
|
|
{!embedded && ( |
|
|
<p className="text-xs text-gray-500"> |
|
|
{job.type === 'model' ? `مدل: ${job.modelName}` : 'اختصاصی'} |
|
|
</p> |
|
|
)} |
|
|
</div> |
|
|
{!embedded && ( |
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white shadow-md |
|
|
${isCompleted ? 'bg-green-500' : isFailed ? 'bg-red-500' : 'bg-amber-400'}`}> |
|
|
<i className={`fas ${isCompleted ? 'fa-check' : isFailed ? 'fa-exclamation' : 'fa-cog fa-spin'}`}></i> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
{!isCompleted && !isFailed && ( |
|
|
<div className="mb-2"> |
|
|
{!embedded && ( |
|
|
<div className="flex justify-between text-xs text-gray-500 mb-1"> |
|
|
<span>پیشرفت کلی</span> |
|
|
<span>{job.progress || 0}%</span> |
|
|
</div> |
|
|
)} |
|
|
<div className={`w-full bg-gray-200 rounded-full overflow-hidden ${embedded ? 'h-1.5' : 'h-2'}`}> |
|
|
<div className="h-full bg-gradient-to-r from-amber-400 to-orange-500 rounded-full transition-all duration-500 relative" |
|
|
style={{ width: `${job.progress || 0}%` }}> |
|
|
<div className="absolute inset-0 bg-white/20 w-full h-full animate-[shimmer_2s_infinite]"></div> |
|
|
</div> |
|
|
</div> |
|
|
<p className="text-center text-[10px] text-gray-500 mt-2 font-medium truncate px-1"> |
|
|
{job.statusMessage || 'در حال انجام عملیات...'} |
|
|
</p> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{isCompleted && ( |
|
|
<div className={`${embedded ? 'mt-1' : 'mt-4 pt-4 border-t border-gray-100'}`}> |
|
|
<audio controls className={`w-full ${embedded ? 'mb-2 h-7' : 'mb-3 h-8'}`} src={downloadUrl} /> |
|
|
<a href="#" onClick={(e) => handleDownload(e, downloadUrl, 'main')} |
|
|
className={`block w-full text-center text-white rounded-xl font-bold transition-all shadow-sm flex items-center justify-center gap-2 |
|
|
${embedded ? 'py-2 text-xs bg-gray-600 hover:bg-gray-700' : 'py-3 bg-gray-600 shadow-gray-200'} |
|
|
${downloadingType === 'main' ? 'opacity-70 cursor-wait' : ''}`}> |
|
|
{downloadingType === 'main' ? <><i className="fas fa-circle-notch fa-spin"></i> آمادهسازی...</> : <><i className="fas fa-download"></i> دانلود صدای اصلی (معمولی)</>} |
|
|
</a> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{isFailed && ( |
|
|
<div onClick={handleCopyErrorLog} |
|
|
className="mt-1 p-2 bg-red-50 text-red-600 rounded-lg text-[10px] text-center border border-red-100 cursor-pointer hover:bg-red-100 transition-colors relative group"> |
|
|
{copySuccess ? <span className="font-bold text-green-600 animate-pulse"><i className="fas fa-check mr-1"></i> متن خطا کپی شد</span> : <span className="flex items-center justify-center gap-1">خطا در پردازش.</span>} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{isCompleted && onEnhance && ( |
|
|
<div className={`mt-4 pt-4 border-t-2 border-dashed ${isEnhanceCompleted ? 'border-amber-200 bg-amber-50/50 -mx-3 px-3 pb-3 rounded-b-xl' : 'border-gray-100'}`}> |
|
|
{!enhanceInfo && ( |
|
|
<button onClick={() => onEnhance(job)} |
|
|
className="group relative w-full overflow-hidden rounded-xl bg-gradient-to-r from-violet-600 to-indigo-600 p-[2px] transition-all hover:shadow-lg hover:shadow-indigo-500/30 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2 active:scale-95"> |
|
|
<div className="relative flex items-center justify-center gap-2 rounded-[10px] bg-white px-4 py-2.5 transition-all group-hover:bg-transparent group-hover:text-white"> |
|
|
<i className="fas fa-wand-magic-sparkles text-lg text-indigo-600 transition-colors group-hover:text-white animate-pulse"></i> |
|
|
<span className="font-black text-sm text-gray-800 transition-colors group-hover:text-white">افزایش کیفیت صدا و حذف نویز (AI)</span> |
|
|
</div> |
|
|
</button> |
|
|
)} |
|
|
{enhanceInfo && isEnhancing && ( |
|
|
<div className="animate-[fadeIn_0.5s_ease-out]"> |
|
|
<div className="flex items-center justify-between mb-2"> |
|
|
<span className="text-xs font-bold text-indigo-700 flex items-center gap-2"><i className="fas fa-sparkles fa-spin text-amber-500"></i>در حال حذف نویز و تقویت صدا...</span> |
|
|
<span className="text-[11px] font-black text-indigo-600">{enhanceInfo.progress || 0}%</span> |
|
|
</div> |
|
|
<div className="h-3 w-full rounded-full bg-indigo-100 overflow-hidden shadow-inner"> |
|
|
<div className="h-full rounded-full bg-gradient-to-r from-violet-500 via-fuchsia-500 to-indigo-500 transition-all duration-500 ease-out relative" style={{ width: `${Math.max(5, enhanceInfo.progress || 0)}%` }}> |
|
|
<div className="absolute inset-0 bg-white/30 w-full h-full animate-[shimmer_1.5s_infinite]"></div> |
|
|
</div> |
|
|
</div> |
|
|
<p className="text-[9px] text-center text-indigo-400 mt-1 font-bold">در حال انجام کار ممکن است زمانبر باشد</p> |
|
|
</div> |
|
|
)} |
|
|
{enhanceInfo && isEnhanceCompleted && ( |
|
|
<div className="animate-[scaleIn_0.4s_cubic-bezier(0.175,0.885,0.32,1.275)]"> |
|
|
<div className="flex items-center gap-2 mb-2 justify-center"> |
|
|
<i className="fas fa-star text-amber-400 text-sm animate-[spin_3s_linear_infinite]"></i> |
|
|
<h5 className="text-xs font-black text-amber-700">نسخه تقویت شده (High Quality)</h5> |
|
|
<i className="fas fa-star text-amber-400 text-sm animate-[spin_3s_linear_infinite_reverse]"></i> |
|
|
</div> |
|
|
<audio controls className="w-full h-7 mb-2 border border-amber-200 rounded-full bg-amber-100" src={enhanceDownloadUrl} /> |
|
|
<a href="#" onClick={(e) => handleDownload(e, enhanceDownloadUrl, 'enhanced')} |
|
|
className={`block w-full text-center text-white rounded-xl font-bold bg-gradient-to-r from-amber-500 to-orange-500 py-2.5 text-xs shadow-lg shadow-amber-500/30 hover:shadow-amber-500/50 transition-all transform hover:-translate-y-0.5 active:translate-y-0 ${downloadingType === 'enhanced' ? 'opacity-70 cursor-wait' : ''}`}> |
|
|
{downloadingType === 'enhanced' ? <><i className="fas fa-circle-notch fa-spin mr-1"></i> در حال آمادهسازی...</> : <><i className="fas fa-download mr-1"></i> دانلود صدای با کیفیت عالی</>} |
|
|
</a> |
|
|
</div> |
|
|
)} |
|
|
{enhanceInfo?.status === 'failed' && ( |
|
|
<div className="text-xs text-red-500 bg-red-50 p-2 rounded border border-red-100 text-center font-bold">خطا در تقویت صدا. لطفا دوباره تلاش کنید.</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const CustomVoice = ({ onStart, isLoading }) => { |
|
|
const [srcFile, setSrcFile] = useState(null); |
|
|
const [refFile, setRefFile] = useState(null); |
|
|
const [playingType, setPlayingType] = useState(null); |
|
|
const audioRef = useRef(null); |
|
|
const srcInputRef = useRef(null); |
|
|
const refInputRef = useRef(null); |
|
|
|
|
|
useEffect(() => { |
|
|
return () => { |
|
|
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
const handleFileChange = (e, type) => { |
|
|
if (e.target.files && e.target.files[0]) { |
|
|
if (playingType === type && audioRef.current) { audioRef.current.pause(); setPlayingType(null); } |
|
|
if (type === 'src') setSrcFile(e.target.files[0]); |
|
|
else setRefFile(e.target.files[0]); |
|
|
} |
|
|
}; |
|
|
|
|
|
const togglePreview = (e, type) => { |
|
|
e.stopPropagation(); |
|
|
const file = type === 'src' ? srcFile : refFile; |
|
|
if (!file) return; |
|
|
if (playingType === type) { |
|
|
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } |
|
|
setPlayingType(null); |
|
|
return; |
|
|
} |
|
|
if (audioRef.current) audioRef.current.pause(); |
|
|
const url = URL.createObjectURL(file); |
|
|
const audio = new Audio(url); |
|
|
audioRef.current = audio; |
|
|
audio.onended = () => { setPlayingType(null); URL.revokeObjectURL(url); }; |
|
|
audio.onerror = () => { alert("خطا در پخش فایل"); setPlayingType(null); }; |
|
|
audio.play(); |
|
|
setPlayingType(type); |
|
|
}; |
|
|
|
|
|
const handleSubmit = (e) => { |
|
|
e.preventDefault(); |
|
|
if (srcFile && refFile) { |
|
|
if (audioRef.current) { audioRef.current.pause(); setPlayingType(null); } |
|
|
onStart(srcFile, refFile); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="animate-[fadeIn_0.5s_ease-out]"> |
|
|
<div className="text-center mb-8"> |
|
|
<div className="w-16 h-16 bg-gradient-to-tr from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center shadow-lg shadow-purple-500/20 rotate-3 transform hover:rotate-6 transition-transform"> |
|
|
<i className="fas fa-sliders-h text-2xl text-white"></i> |
|
|
</div> |
|
|
<h2 className="text-2xl font-black text-gray-800 mb-2">استودیو ساخت صدای اختصاصی</h2> |
|
|
<p className="text-gray-700 text-sm font-bold mb-2 px-4 leading-relaxed">با داشتن یک نمونه صدا هر صدایی رو به همون صدا تبدیل کنید</p> |
|
|
<p className="text-gray-500 text-xs px-6 leading-relaxed">برای شبیهسازی دقیق، صدای خود و صدای هدف را با کیفیت بالا بارگذاری کنید.</p> |
|
|
</div> |
|
|
<form onSubmit={handleSubmit} className="space-y-6 px-2"> |
|
|
<div onClick={() => srcInputRef.current?.click()} className={`relative overflow-hidden rounded-3xl border-2 border-dashed p-6 transition-all duration-300 active:scale-95 cursor-pointer ${srcFile ? 'border-primary bg-blue-50/50' : 'border-gray-200 bg-white hover:bg-gray-50'}`}> |
|
|
<input type="file" ref={srcInputRef} hidden accept="audio/*" onChange={(e) => handleFileChange(e, 'src')} /> |
|
|
<div className="flex items-center gap-4"> |
|
|
<button type="button" onClick={(e) => { if (srcFile) togglePreview(e, 'src'); }} className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 transition-all duration-300 relative outline-none ${srcFile ? 'bg-primary text-white shadow-lg shadow-indigo-500/30 hover:scale-110 z-10' : 'bg-gray-100 text-gray-400'}`}> |
|
|
{srcFile ? (playingType === 'src' ? <><span className="absolute inset-0 rounded-full bg-white/30 animate-ping"></span><i className="fas fa-stop text-sm relative z-10"></i></> : <i className="fas fa-play ml-1 text-sm"></i>) : <i className="fas fa-microphone text-xl"></i>} |
|
|
</button> |
|
|
<div className="text-right flex-1"> |
|
|
<h6 className="font-bold text-gray-700 text-sm">صدای ورودی (ویس شما)</h6> |
|
|
<p className="text-xs text-gray-400 mt-1 truncate">{srcFile ? srcFile.name : 'برای انتخاب ضربه بزنید'}</p> |
|
|
</div> |
|
|
{srcFile && <i className="fas fa-check-circle text-primary text-xl"></i>} |
|
|
</div> |
|
|
</div> |
|
|
<div onClick={() => refInputRef.current?.click()} className={`relative overflow-hidden rounded-3xl border-2 border-dashed p-6 transition-all duration-300 active:scale-95 cursor-pointer ${refFile ? 'border-secondary bg-purple-50/50' : 'border-gray-200 bg-white hover:bg-gray-50'}`}> |
|
|
<input type="file" ref={refInputRef} hidden accept="audio/*" onChange={(e) => handleFileChange(e, 'ref')} /> |
|
|
<div className="flex items-center gap-4"> |
|
|
<button type="button" onClick={(e) => { if (refFile) togglePreview(e, 'ref'); }} className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 transition-all duration-300 relative outline-none ${refFile ? 'bg-secondary text-white shadow-lg shadow-purple-500/30 hover:scale-110 z-10' : 'bg-gray-100 text-gray-400'}`}> |
|
|
{refFile ? (playingType === 'ref' ? <><span className="absolute inset-0 rounded-full bg-white/30 animate-ping"></span><i className="fas fa-stop text-sm relative z-10"></i></> : <i className="fas fa-play ml-1 text-sm"></i>) : <i className="fas fa-music text-xl"></i>} |
|
|
</button> |
|
|
<div className="text-right flex-1"> |
|
|
<h6 className="font-bold text-gray-700 text-sm">صدای الگو (رفرنس)</h6> |
|
|
<p className="text-xs text-gray-400 mt-1 truncate">{refFile ? refFile.name : 'برای انتخاب ضربه بزنید'}</p> |
|
|
</div> |
|
|
{refFile && <i className="fas fa-check-circle text-secondary text-xl"></i>} |
|
|
</div> |
|
|
</div> |
|
|
<button type="submit" disabled={!srcFile || !refFile || isLoading} className={`w-full py-4 rounded-2xl font-bold text-white shadow-xl shadow-purple-500/20 transition-all transform active:scale-95 ${(!srcFile || !refFile || isLoading) ? 'bg-gray-300 cursor-not-allowed shadow-none grayscale' : 'bg-gradient-to-r from-blue-600 to-purple-600'}`}> |
|
|
{isLoading ? <span className="flex items-center justify-center gap-2"><i className="fas fa-circle-notch fa-spin"></i> در حال پردازش...</span> : 'شروع پردازش'} |
|
|
</button> |
|
|
<div className="relative group overflow-hidden rounded-[2.5rem] p-6 transition-all duration-500 hover:shadow-2xl hover:shadow-amber-200/50 border border-amber-100/40"> |
|
|
<div className="absolute inset-0 bg-gradient-to-br from-amber-50/95 via-orange-50/90 to-amber-100/95 backdrop-blur-md -z-10"></div> |
|
|
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-r from-transparent via-white/50 to-transparent -translate-x-full animate-[shimmer_4s_infinite] -z-10"></div> |
|
|
<div className="absolute -bottom-12 -right-12 w-40 h-40 bg-amber-200/20 rounded-full blur-3xl group-hover:bg-amber-300/30 transition-colors duration-1000"></div> |
|
|
<div className="space-y-6 relative z-10"> |
|
|
<div className="flex gap-4 items-start"> |
|
|
<div className="relative flex-shrink-0"> |
|
|
<div className="absolute inset-0 bg-amber-400 blur-md opacity-30 rounded-full animate-pulse"></div> |
|
|
<div className="w-10 h-10 bg-white rounded-2xl shadow-sm border border-amber-100 flex items-center justify-center relative transform group-hover:scale-110 transition-transform"> |
|
|
<i className="fas fa-lightbulb text-amber-500 text-lg animate-[bounce_3s_infinite]"></i> |
|
|
</div> |
|
|
</div> |
|
|
<div className="text-right flex-1"> |
|
|
<h5 className="text-amber-950 font-black text-sm mb-2">راهنمای هوشمند</h5> |
|
|
<p className="text-[11.5px] text-amber-900 leading-[1.8] font-medium opacity-95">در قسمت <span className="font-black text-amber-950 underline decoration-amber-300/60 decoration-4 underline-offset-4">صدای ورودی</span> صدای خودتان و در قسمت <span className="font-black text-amber-950 underline decoration-amber-300/60 decoration-4 underline-offset-4">صدای الگو</span> صدای هدف را بارگذاری کنید.</p> |
|
|
</div> |
|
|
</div> |
|
|
<div className="flex gap-4 items-start border-t border-amber-200/40 pt-5"> |
|
|
<div className="relative flex-shrink-0"> |
|
|
<div className="absolute inset-0 bg-amber-400 blur-md opacity-20 rounded-full"></div> |
|
|
<div className="w-10 h-10 bg-white rounded-2xl shadow-sm border border-amber-100 flex items-center justify-center relative transform group-hover:rotate-12 transition-transform"> |
|
|
<i className="fas fa-stopwatch text-amber-600 text-lg"></i> |
|
|
</div> |
|
|
</div> |
|
|
<div className="text-right flex-1 pt-1"> |
|
|
<p className="text-[11px] text-amber-950 font-bold leading-relaxed">نکته: بهترین حالت این است که صدای مدل بین <span className="text-sm font-black text-amber-600 underline decoration-amber-300/60 decoration-4 underline-offset-4 mx-1">۳ تا ۹</span> ثانیه بدون نویز باشه.</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const ModelGrid = ({ customModels, onSelectModel, onOpenCustom, onAddNewModel, onEditModel }) => { |
|
|
const [activeTab, setActiveTab] = useState('all'); |
|
|
const [playingModelId, setPlayingModelId] = useState(null); |
|
|
const audioRef = useRef(null); |
|
|
|
|
|
const categories = [ |
|
|
{ key: 'singers', title: 'خوانندگان', icon: 'fa-music' }, |
|
|
{ key: 'dubbers', title: 'دوبلورها', icon: 'fa-microphone-alt' }, |
|
|
{ key: 'cartoons', title: 'کارتون', icon: 'fa-child' }, |
|
|
{ key: 'famous', title: 'مشاهیر', icon: 'fa-star' }, |
|
|
{ key: 'alpha', title: 'گویندگان آلفا', icon: 'fa-user-tie' }, |
|
|
]; |
|
|
|
|
|
useEffect(() => { |
|
|
return () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } }; |
|
|
}, []); |
|
|
|
|
|
const togglePreview = (e, model) => { |
|
|
e.stopPropagation(); |
|
|
if (playingModelId === model.id) { |
|
|
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } |
|
|
setPlayingModelId(null); |
|
|
return; |
|
|
} |
|
|
if (audioRef.current) audioRef.current.pause(); |
|
|
let audioSrc = model.sampleAudio; |
|
|
if (!audioSrc && model.isCustom && typeof model.refFile !== 'string') { |
|
|
audioSrc = URL.createObjectURL(model.refFile); |
|
|
} |
|
|
if (audioSrc) { |
|
|
const audio = new Audio(audioSrc); |
|
|
audioRef.current = audio; |
|
|
audio.onerror = () => setPlayingModelId(null); |
|
|
audio.play().catch(() => setPlayingModelId(null)); |
|
|
setPlayingModelId(model.id); |
|
|
audio.onended = () => { setPlayingModelId(null); audioRef.current = null; }; |
|
|
} |
|
|
}; |
|
|
|
|
|
const renderModelCard = (model) => { |
|
|
const imageUrl = (model.isCustom && typeof model.image !== 'string') ? URL.createObjectURL(model.image) : model.image; |
|
|
return ( |
|
|
<div key={model.id} onClick={() => onSelectModel(model)} className="group relative cursor-pointer rounded-2xl overflow-hidden aspect-square shadow-sm hover:shadow-xl transition-all duration-300 active:scale-95 bg-gray-100"> |
|
|
<img src={imageUrl} alt={model.name} loading="lazy" className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" /> |
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-80"></div> |
|
|
<button onClick={(e) => togglePreview(e, model)} className={`absolute top-2 left-2 w-8 h-8 rounded-full flex items-center justify-center backdrop-blur-md shadow-sm transition-all duration-300 z-20 ${playingModelId === model.id ? 'bg-red-500/90 text-white scale-110 shadow-red-500/50' : 'bg-white/30 hover:bg-white/50 text-white hover:scale-105'}`}> |
|
|
{playingModelId === model.id ? <div className="flex items-center justify-center h-full w-full relative"><i className="fas fa-stop text-xs relative z-10"></i><span className="absolute inset-0 rounded-full bg-red-400 animate-ping opacity-75"></span></div> : <i className="fas fa-play text-xs ml-0.5"></i>} |
|
|
</button> |
|
|
{model.isCustom && ( |
|
|
<button onClick={(e) => { e.stopPropagation(); onEditModel(model); }} className="absolute top-2 right-2 w-8 h-8 rounded-full bg-amber-500/90 text-white flex items-center justify-center shadow-lg hover:scale-110 transition-all z-20"><i className="fas fa-edit text-xs"></i></button> |
|
|
)} |
|
|
<div className="absolute bottom-0 left-0 right-0 p-3"> |
|
|
<span className="block text-white text-sm font-bold truncate">{model.name}</span> |
|
|
<span className="text-[10px] text-gray-300 flex items-center gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 transform translate-y-2 group-hover:translate-y-0"><i className="fas fa-wave-square"></i> انتخاب مدل</span> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="pb-24 animate-[fadeIn_0.5s_ease-out]"> |
|
|
<div onClick={onOpenCustom} className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-gray-900 to-gray-800 p-6 mb-6 shadow-xl shadow-gray-900/20 cursor-pointer group"> |
|
|
<div className="absolute top-0 left-0 w-full h-full opacity-10 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')]"></div> |
|
|
<div className="relative z-10 flex items-center justify-between"> |
|
|
<div><h3 className="text-white font-bold text-lg mb-1">هنوز مدل دلخواهت رو پیدا نکردی؟</h3><p className="text-gray-400 text-xs">ساخت صدای اختصاصی با آپلود فایل</p></div> |
|
|
<div className="w-10 h-10 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center group-hover:bg-white/20 transition-colors"><i className="fas fa-arrow-left text-white"></i></div> |
|
|
</div> |
|
|
</div> |
|
|
<div className="flex overflow-x-auto pb-4 gap-3 mb-2 px-1 no-scrollbar"> |
|
|
<button onClick={() => setActiveTab('all')} className={`flex-shrink-0 px-5 py-2.5 rounded-full text-sm font-bold transition-all duration-300 border ${activeTab === 'all' ? 'bg-gradient-to-r from-primary to-secondary text-white border-transparent shadow-lg shadow-indigo-200' : 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'}`}>همه</button> |
|
|
{categories.map((cat) => ( |
|
|
<button key={cat.key} onClick={() => setActiveTab(cat.key)} className={`flex-shrink-0 px-4 py-2.5 rounded-full text-sm font-bold transition-all duration-300 border flex items-center gap-2 ${activeTab === cat.key ? 'bg-gradient-to-r from-primary to-secondary text-white border-transparent shadow-lg shadow-indigo-200' : 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'}`}> |
|
|
<i className={`fas ${cat.icon} text-xs`}></i>{cat.title} |
|
|
</button> |
|
|
))} |
|
|
</div> |
|
|
<div className="min-h-[300px]"> |
|
|
{categories.map((category) => { |
|
|
if (activeTab !== 'all' && activeTab !== category.key) return null; |
|
|
const categoryModels = MODELS.filter(m => m.category === category.key); |
|
|
const isAlpha = category.key === 'alpha'; |
|
|
return ( |
|
|
<div key={category.key} className="mb-8 animate-[fadeIn_0.5s_ease-out]"> |
|
|
<div className="flex items-center gap-2 mb-4 px-2"> |
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white shadow-sm ${activeTab === category.key ? 'bg-secondary' : 'bg-gray-300'}`}><i className={`fas ${category.icon} text-xs`}></i></div> |
|
|
<h2 className="text-lg font-black text-gray-800">{category.title}</h2> |
|
|
{activeTab === 'all' && <div className="h-px bg-gray-100 flex-1 ml-4"></div>} |
|
|
</div> |
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> |
|
|
{categoryModels.map(renderModelCard)} |
|
|
{isAlpha && customModels.map(renderModelCard)} |
|
|
{isAlpha && ( |
|
|
<div onClick={onAddNewModel} className="relative group cursor-pointer rounded-2xl aspect-square border-4 border-dashed border-gray-200 flex flex-col items-center justify-center gap-3 transition-all duration-300 hover:border-primary/50 hover:bg-primary/5 active:scale-95"> |
|
|
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-white shadow-lg shadow-primary/20 group-hover:scale-110 transition-transform"><i className="fas fa-plus text-2xl animate-[pulse_2s_infinite]"></i></div> |
|
|
<span className="text-[10px] font-black text-gray-500 group-hover:text-primary transition-colors text-center px-1">ساخت مدل صدای دلخواه</span> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const ModelModal = ({ model, isOpen, onClose, onConfirm, isLoading }) => { |
|
|
const [activeMode, setActiveMode] = useState('vc'); |
|
|
const [file, setFile] = useState(null); |
|
|
const [fileUrl, setFileUrl] = useState(null); |
|
|
const [text, setText] = useState(''); |
|
|
const [pitch, setPitch] = useState(null); |
|
|
const inputRef = useRef(null); |
|
|
|
|
|
useEffect(() => { |
|
|
if (isOpen) { setFile(null); setFileUrl(null); setText(''); setPitch(null); } |
|
|
}, [isOpen]); |
|
|
|
|
|
useEffect(() => { |
|
|
return () => { if (fileUrl) URL.revokeObjectURL(fileUrl); }; |
|
|
}, [fileUrl]); |
|
|
|
|
|
const handleFileChange = (selectedFile) => { |
|
|
if (fileUrl) URL.revokeObjectURL(fileUrl); |
|
|
setFile(selectedFile); |
|
|
setFileUrl(URL.createObjectURL(selectedFile)); |
|
|
}; |
|
|
|
|
|
if (!isOpen || !model) return null; |
|
|
|
|
|
const handleConfirm = () => { |
|
|
if (activeMode === 'vc' && file) { |
|
|
onConfirm({ file, mode: 'vc', pitch: pitch ?? 0 }); |
|
|
} else if (activeMode === 'tts' && text.trim().length > 0) { |
|
|
onConfirm({ text, mode: 'tts', pitch: pitch ?? 0 }); |
|
|
} |
|
|
}; |
|
|
|
|
|
const isLegacy = model.type === 'legacy_rvc'; |
|
|
const targetIsFemale = model.targetGender === 'female'; |
|
|
const setPitchForGender = (inputGender) => { |
|
|
if (targetIsFemale) { if (inputGender === 'male') setPitch(12); else setPitch(0); } |
|
|
else { if (inputGender === 'male') setPitch(0); else setPitch(-12); } |
|
|
}; |
|
|
const isMaleSelected = targetIsFemale ? pitch === 12 : pitch === 0; |
|
|
const isFemaleSelected = targetIsFemale ? pitch === 0 : pitch === -12; |
|
|
const isReady = activeMode === 'vc' ? (!!file && (!isLegacy || pitch !== null)) : text.trim().length > 0; |
|
|
const activeColor = activeMode === 'vc' ? 'bg-gradient-to-r from-blue-500 to-indigo-600' : 'bg-gradient-to-r from-teal-400 to-teal-600'; |
|
|
|
|
|
const getModelImage = () => { |
|
|
if (model.isCustom && model.image && typeof model.image !== 'string') { |
|
|
try { return URL.createObjectURL(model.image); } catch (e) { return ''; } |
|
|
} |
|
|
return model.image || ''; |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4"> |
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity" onClick={onClose}></div> |
|
|
<div className="relative bg-white rounded-3xl w-full max-w-[350px] shadow-2xl animate-[fadeIn_0.3s_ease-out] overflow-hidden flex flex-col max-h-[90vh]"> |
|
|
<div className="overflow-y-auto p-5 custom-scrollbar"> |
|
|
<button onClick={onClose} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors z-10"><i className="fas fa-times text-xl"></i></button> |
|
|
<div className="text-center mb-6 mt-2"> |
|
|
<div className="w-16 h-16 mx-auto rounded-full p-1 bg-gradient-to-tr from-primary to-secondary mb-3 shadow-lg overflow-hidden"> |
|
|
<img src={getModelImage()} alt={model.name} className="w-full h-full object-cover rounded-full border-2 border-white" /> |
|
|
</div> |
|
|
<h3 className="text-xl font-black text-gray-800">{model.name}</h3> |
|
|
</div> |
|
|
<div className="relative flex bg-gray-100 p-1 rounded-xl mb-6 h-14 shadow-inner"> |
|
|
<div className={`absolute top-1 bottom-1 w-[calc(50%-4px)] rounded-lg shadow-sm transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] ${activeMode === 'vc' ? 'right-1 translate-x-0 bg-gradient-to-r from-blue-500 to-indigo-600' : 'right-1 -translate-x-[100%] -ml-2 bg-gradient-to-r from-teal-400 to-emerald-500'}`}></div> |
|
|
<button onClick={() => setActiveMode('vc')} className={`flex-1 relative z-10 text-sm font-bold text-center transition-colors duration-200 flex items-center justify-center gap-2 ${activeMode === 'vc' ? 'text-white' : 'text-gray-600 hover:text-gray-800'}`}><i className="fas fa-microphone-alt text-lg"></i> تغییر صدا</button> |
|
|
<button onClick={() => setActiveMode('tts')} className={`flex-1 relative z-10 text-sm font-bold text-center transition-colors duration-200 flex items-center justify-center gap-2 ${activeMode === 'tts' ? 'text-white' : 'text-gray-600 hover:text-gray-800'}`}><i className="fas fa-keyboard text-lg"></i> متن به صدا</button> |
|
|
</div> |
|
|
<div className={`min-h-[160px] rounded-2xl p-2 border-2 transition-all duration-300 ${activeMode === 'vc' ? 'border-blue-100 bg-blue-50/30' : 'border-teal-100 bg-teal-50/30'}`}> |
|
|
{activeMode === 'vc' ? ( |
|
|
<div className="animate-[fadeIn_0.3s_ease-out] h-full flex flex-col justify-center"> |
|
|
{!file && ( |
|
|
<div onClick={() => inputRef.current?.click()} className="flex-1 border-2 border-dashed border-blue-200 rounded-xl p-5 text-center cursor-pointer hover:border-blue-400 hover:bg-white/50 transition-all flex flex-col items-center justify-center min-h-[140px]"> |
|
|
<input type="file" ref={inputRef} hidden accept="audio/*" onChange={(e) => e.target.files && e.target.files[0] && handleFileChange(e.target.files[0])} /> |
|
|
<div className="w-12 h-12 rounded-full mb-3 flex items-center justify-center bg-blue-100 text-blue-500"><i className="fas fa-cloud-upload-alt text-xl"></i></div> |
|
|
<p className="font-bold text-gray-700 text-sm mb-1 text-center leading-relaxed">برای انتخاب فایل صوتی کلیک کنید</p> |
|
|
</div> |
|
|
)} |
|
|
{file && ( |
|
|
<div className="w-full animate-[fadeIn_0.3s_ease-out]"> |
|
|
<div className="text-center py-2"> |
|
|
<p className="text-xs text-gray-500 mb-2 flex items-center justify-center gap-2"><i className="fas fa-headphones-alt text-primary"></i> پیشنمایش صدای شما</p> |
|
|
<audio controls src={fileUrl || ''} className="w-full h-8 rounded-lg shadow-sm" /> |
|
|
<div className="flex items-center justify-between mt-2 px-1"> |
|
|
<span className="text-[10px] text-gray-400 truncate max-w-[150px] dir-ltr">{file.name}</span> |
|
|
<button onClick={() => { setFile(null); setPitch(null); }} className="text-[10px] text-red-500 hover:text-red-700 font-bold"><i className="fas fa-sync-alt mr-1"></i> تعویض فایل</button> |
|
|
</div> |
|
|
</div> |
|
|
{isLegacy && ( |
|
|
<div className="mt-2 animate-[fadeIn_0.3s_ease-out]"> |
|
|
<hr className="border-blue-200/50 mb-3" /> |
|
|
<div> |
|
|
<p className="text-xs text-center text-gray-600 mb-3 font-bold">جنسیت صدای این فایل چیست؟</p> |
|
|
<div className="flex gap-3"> |
|
|
<div onClick={() => setPitchForGender('male')} className={`flex-1 p-3 rounded-xl border-2 cursor-pointer transition-all flex flex-col items-center gap-2 shadow-sm ${isMaleSelected ? 'border-blue-500 bg-blue-600 text-white transform scale-105 shadow-blue-200' : 'border-gray-200 bg-white text-gray-500 hover:border-blue-200 hover:bg-gray-50'}`}><i className="fas fa-male text-2xl"></i><span className="text-xs font-bold">مرد</span></div> |
|
|
<div onClick={() => setPitchForGender('female')} className={`flex-1 p-3 rounded-xl border-2 cursor-pointer transition-all flex flex-col items-center gap-2 shadow-sm ${isFemaleSelected ? 'border-pink-500 bg-pink-500 text-white transform scale-105 shadow-pink-200' : 'border-gray-200 bg-white text-gray-500 hover:border-pink-200 hover:bg-gray-50'}`}><i className="fas fa-female text-2xl"></i><span className="text-xs font-bold">زن</span></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
) : ( |
|
|
<textarea value={text} onChange={(e) => setText(e.target.value)} placeholder="متن را اینجا بنویسید..." className="flex-1 w-full p-4 rounded-xl border border-teal-200 focus:border-teal-500 focus:ring-2 focus:ring-teal-500/20 outline-none resize-none bg-white text-gray-700 text-sm leading-relaxed shadow-sm min-h-[140px]"></textarea> |
|
|
)} |
|
|
</div> |
|
|
<button onClick={handleConfirm} disabled={!isReady || isLoading} className={`w-full py-4 rounded-2xl font-bold text-white text-base shadow-md transition-all mt-6 transform active:scale-95 ${(!isReady || isLoading) ? 'bg-gray-300 cursor-not-allowed shadow-none' : `${activeColor} hover:shadow-lg shadow-blue-200`}`}> |
|
|
{isLoading ? <span className="flex items-center justify-center gap-2"><i className="fas fa-circle-notch fa-spin"></i> در حال پردازش...</span> : <span className="flex items-center justify-center gap-2"><span>شروع پردازش</span><i className={`fas ${activeMode === 'vc' ? 'fa-magic' : 'fa-play'}`}></i></span>} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const CreateModelModal = ({ isOpen, onClose, onSave, onDelete, editingModel }) => { |
|
|
const [name, setName] = useState(''); |
|
|
const [image, setImage] = useState(null); |
|
|
const [audio, setAudio] = useState(null); |
|
|
const [gender, setGender] = useState('male'); |
|
|
const imgInputRef = useRef(null); |
|
|
const audioInputRef = useRef(null); |
|
|
|
|
|
useEffect(() => { |
|
|
if (editingModel) { |
|
|
setName(editingModel.name); setImage(editingModel.image); setAudio(null); setGender(editingModel.targetGender || 'male'); |
|
|
} else { |
|
|
setName(''); setImage(null); setAudio(null); setGender('male'); |
|
|
} |
|
|
}, [editingModel, isOpen]); |
|
|
|
|
|
if (!isOpen) return null; |
|
|
|
|
|
const handleSave = () => { |
|
|
if (!name || !image || (!audio && !editingModel)) return; |
|
|
const model = { |
|
|
id: editingModel?.id || `user_${Date.now()}`, |
|
|
name, |
|
|
image: image, |
|
|
refFile: audio || editingModel?.refFile, |
|
|
category: 'user_custom', |
|
|
targetGender: gender, |
|
|
isCustom: true, |
|
|
type: 'standard' |
|
|
}; |
|
|
onSave(model); |
|
|
}; |
|
|
|
|
|
const getImgPreview = () => { |
|
|
if (!image) return null; |
|
|
if (typeof image === 'string') return image; |
|
|
return URL.createObjectURL(image); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4"> |
|
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-md" onClick={onClose}></div> |
|
|
<div className="relative bg-white rounded-[2.5rem] w-full max-w-[380px] p-6 shadow-2xl animate-[scaleIn_0.3s_ease-out] overflow-hidden"> |
|
|
<div className="text-center mb-6"><h3 className="text-xl font-black text-gray-800">{editingModel ? 'ویرایش مدل' : 'ساخت مدل صوتی جدید'}</h3></div> |
|
|
<div className="space-y-5"> |
|
|
<div className="flex flex-col items-center"> |
|
|
<div onClick={() => imgInputRef.current?.click()} className="w-24 h-24 rounded-3xl border-2 border-dashed border-gray-200 overflow-hidden cursor-pointer hover:border-primary transition-all relative group"> |
|
|
{image ? <img src={getImgPreview()} className="w-full h-full object-cover" /> : <div className="w-full h-full flex flex-col items-center justify-center bg-gray-50"><i className="fas fa-image text-gray-300 text-2xl"></i></div>} |
|
|
</div> |
|
|
<input type="file" ref={imgInputRef} hidden accept="image/*" onChange={(e) => setImage(e.target.files[0])} /> |
|
|
</div> |
|
|
<div><label className="block text-[10px] font-bold text-gray-400 mb-1 mr-2">نام مدل</label><input type="text" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-4 py-3 rounded-xl border border-gray-100 bg-gray-50 focus:bg-white focus:border-primary outline-none text-sm transition-all" /></div> |
|
|
<div onClick={() => audioInputRef.current?.click()} className={`p-4 rounded-xl border-2 border-dashed transition-all cursor-pointer flex flex-col gap-2 text-right ${audio ? 'border-green-400 bg-green-50' : 'border-gray-100 bg-gray-50 hover:border-primary'}`}> |
|
|
<input type="file" ref={audioInputRef} hidden accept="audio/*" onChange={(e) => setAudio(e.target.files[0])} /> |
|
|
<div className="flex items-center gap-3"> |
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${audio ? 'bg-green-500 text-white' : 'bg-white text-gray-400 shadow-sm'}`}><i className={`fas ${audio ? 'fa-check' : 'fa-microphone'}`}></i></div> |
|
|
<div className="flex-1"><p className="text-xs font-bold text-gray-700">{audio ? 'فایل صوتی انتخاب شد' : 'آپلود فایل صوتی مرجع'}</p></div> |
|
|
</div> |
|
|
</div> |
|
|
<div className="flex gap-2"> |
|
|
<button onClick={() => setGender('male')} className={`flex-1 py-2 rounded-xl text-xs font-bold transition-all border ${gender === 'male' ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-400 border-gray-100'}`}>مرد</button> |
|
|
<button onClick={() => setGender('female')} className={`flex-1 py-2 rounded-xl text-xs font-bold transition-all border ${gender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'bg-white text-gray-400 border-gray-100'}`}>زن</button> |
|
|
</div> |
|
|
<div className="flex gap-2 pt-2"> |
|
|
<button onClick={handleSave} disabled={!name || !image || (!audio && !editingModel)} className="flex-[2] py-4 bg-gradient-to-r from-primary to-secondary text-white rounded-2xl font-black text-sm shadow-xl shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all disabled:grayscale disabled:opacity-50">{editingModel ? 'بروزرسانی مدل' : 'ساخت و ذخیره مدل'}</button> |
|
|
{editingModel && onDelete && <button onClick={() => onDelete(editingModel.id)} className="flex-1 bg-red-50 text-red-500 rounded-2xl flex items-center justify-center hover:bg-red-100 transition-colors"><i className="fas fa-trash-alt"></i></button>} |
|
|
</div> |
|
|
<button onClick={onClose} className="w-full py-2 text-xs text-gray-400 font-bold">انصراف</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const History = ({ jobs, onClearAll, onDeleteSingle, onSelect, onEnhance }) => { |
|
|
const sortedJobs = [...jobs].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); |
|
|
const [expandedId, setExpandedId] = useState(null); |
|
|
const toggleExpand = (job) => { if (expandedId === job.job_id) setExpandedId(null); else { setExpandedId(job.job_id); onSelect(job); } }; |
|
|
|
|
|
const getModelImageUrl = (job) => { |
|
|
if (job.modelImage) { |
|
|
if (typeof job.modelImage === 'string') return job.modelImage; |
|
|
try { return URL.createObjectURL(job.modelImage); } catch (e) { return null; } |
|
|
} |
|
|
if (job.type === 'model' && job.modelName) { |
|
|
const found = MODELS.find(m => m.name === job.modelName); |
|
|
if (found) return found.image; |
|
|
} |
|
|
return null; |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="animate-[fadeIn_0.5s_ease-out]"> |
|
|
<div className="flex items-center justify-between mb-4 px-3 mt-2"> |
|
|
<div><h2 className="text-xl font-black text-gray-800">سوابق پروژه</h2><p className="text-xs text-gray-500 mt-1">لیست درخواستهای پردازش شده</p></div> |
|
|
{jobs.length > 0 && <button onClick={onClearAll} className="px-3 py-1.5 rounded-full bg-red-50 text-red-500 hover:bg-red-100 flex items-center gap-2 transition-colors shadow-sm text-xs font-bold"><i className="fas fa-trash-alt"></i> حذف همه</button>} |
|
|
</div> |
|
|
<div className="space-y-3 pb-24 px-1"> |
|
|
{jobs.length === 0 ? ( |
|
|
<div className="text-center py-24 opacity-50"> |
|
|
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4 grayscale"><i className="fas fa-folder-open text-3xl text-gray-300"></i></div> |
|
|
<p className="text-gray-400 font-medium text-sm">هنوز هیچ پروژهای انجام نشده است</p> |
|
|
</div> |
|
|
) : ( |
|
|
sortedJobs.map((job) => { |
|
|
const isExpanded = expandedId === job.job_id; |
|
|
const modelImage = getModelImageUrl(job); |
|
|
return ( |
|
|
<div key={job.job_id} onClick={() => toggleExpand(job)} className={`group relative bg-white rounded-2xl px-4 py-4 border transition-all duration-300 cursor-pointer overflow-hidden ${isExpanded ? 'border-primary shadow-lg ring-1 ring-primary/10' : 'border-gray-100 shadow-sm hover:shadow-md'}`}> |
|
|
<div className="flex items-center justify-between"> |
|
|
<div className="flex items-center gap-4 overflow-hidden"> |
|
|
<div className={`w-12 h-12 flex-shrink-0 rounded-full flex items-center justify-center shadow-md transition-all duration-300 border-2 border-white overflow-hidden ${modelImage ? 'bg-gray-200' : (job.type === 'model' ? 'bg-gradient-to-br from-indigo-500 to-purple-600' : 'bg-gradient-to-br from-gray-700 to-gray-900')}`}> |
|
|
{modelImage ? <img src={modelImage} alt="Model" className="w-full h-full object-cover" /> : <div className="text-white">{job.type === 'model' ? <i className="fas fa-user text-base"></i> : <i className="fas fa-fingerprint text-base"></i>}</div>} |
|
|
</div> |
|
|
<div className="min-w-0"> |
|
|
<h3 className="font-bold text-gray-800 text-sm truncate leading-tight">{job.type === 'model' ? job.modelName : 'پروژه اختصاصی'}</h3> |
|
|
<div className="flex items-center gap-2 mt-1.5"> |
|
|
<span className={`text-[10px] px-2 py-0.5 rounded-md font-bold border ${job.status === 'completed' ? 'bg-green-50 text-green-600 border-green-100' : job.status === 'failed' ? 'bg-red-50 text-red-600 border-red-100' : 'bg-amber-50 text-amber-600 border-amber-100'}`}>{job.status === 'completed' ? 'تکمیل شده' : job.status === 'failed' ? 'خطا' : 'در حال کار'}</span> |
|
|
<span className="text-[10px] text-gray-400 dir-ltr font-medium">{job.date?.split(',')[0]}</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div className="flex items-center gap-3"> |
|
|
<i className={`fas fa-chevron-left text-gray-300 text-sm transition-transform duration-300 ${isExpanded ? '-rotate-90 text-primary' : ''}`}></i> |
|
|
<button onClick={(e) => { e.stopPropagation(); onDeleteSingle(job.job_id); }} className="w-8 h-8 flex items-center justify-center rounded-full text-gray-300 bg-gray-50 hover:text-red-500 hover:bg-red-50 transition-colors z-10 text-sm border border-transparent hover:border-red-100"><i className="fas fa-trash"></i></button> |
|
|
</div> |
|
|
</div> |
|
|
<div className={`grid transition-[grid-template-rows] duration-300 ease-out ${isExpanded ? 'grid-rows-[1fr] opacity-100 mt-2' : 'grid-rows-[0fr] opacity-0 mt-0'}`}> |
|
|
<div className="overflow-hidden"> |
|
|
<ProcessingPanel job={job} embedded={true} onEnhance={onEnhance} /> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}) |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const BottomNav = ({ activeTab, onChange, badgeCount, historyRef }) => { |
|
|
const tabs = [ |
|
|
{ id: 'home', icon: 'fa-house', label: 'خانه' }, |
|
|
{ id: 'custom', icon: 'fa-microphone-lines', label: 'ساخت صدا' }, |
|
|
{ id: 'history', icon: 'fa-clock-rotate-left', label: 'سوابق', ref: historyRef }, |
|
|
]; |
|
|
return ( |
|
|
<div className="fixed bottom-6 left-0 right-0 z-50 px-6 pointer-events-none"> |
|
|
<div className="max-w-[360px] mx-auto pointer-events-auto relative"> |
|
|
<div className="absolute -inset-[2px] rounded-full overflow-hidden"> |
|
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200%] h-[1000%] bg-[conic-gradient(from_0deg_at_50%_50%,transparent_0%,rgba(30,41,59,0.15)_50%,transparent_100%)] animate-[spin_10s_linear_infinite]"></div> |
|
|
</div> |
|
|
<div className="relative bg-white/95 backdrop-blur-xl shadow-[0_10px_40px_-10px_rgba(0,0,0,0.1)] border border-white/50 rounded-full h-[70px] flex items-center justify-between px-2"> |
|
|
{tabs.map((tab) => { |
|
|
const isActive = activeTab === tab.id; |
|
|
return ( |
|
|
<button key={tab.id} ref={tab.ref} onClick={() => onChange(tab.id)} className="relative flex-1 h-full flex flex-col items-center justify-center group rounded-full overflow-hidden"> |
|
|
<div className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full rounded-full transition-all duration-500 ease-out -z-10 ${isActive ? 'bg-gradient-to-t from-gray-50 to-transparent opacity-100' : 'opacity-0'}`}></div> |
|
|
{tab.id === 'history' && badgeCount > 0 && <span className="absolute top-3 right-[28%] flex h-2 w-2 z-20"><span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span><span className="relative inline-flex rounded-full h-2 w-2 bg-red-500 border border-white"></span></span>} |
|
|
<div className={`relative w-12 h-8 flex items-center justify-center mb-0.5 transition-all duration-300 ${isActive ? '-translate-y-1' : 'translate-y-0'}`}> |
|
|
{isActive && <div className="absolute inset-0 bg-blue-500/10 blur-xl rounded-full"></div>} |
|
|
<i className={`fas ${tab.icon} text-xl transition-all duration-300 z-10 ${isActive ? 'text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600 scale-110 drop-shadow-sm' : 'text-gray-400 group-hover:text-gray-600'}`}></i> |
|
|
</div> |
|
|
<span className={`text-[10px] font-bold transition-all duration-300 absolute bottom-3 ${isActive ? 'text-gray-800' : 'text-gray-400'}`}>{tab.label}</span> |
|
|
<div className={`absolute bottom-1 w-1 h-1 rounded-full bg-gradient-to-r from-blue-600 to-purple-600 transition-all duration-300 ${isActive ? 'opacity-100 scale-100' : 'opacity-0 scale-0'}`}></div> |
|
|
</button> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const UpgradeModal = ({ isOpen, onClose, onUpgrade, resetDate, mode = 'limit' }) => { |
|
|
if (!isOpen) return null; |
|
|
const resetDateStr = resetDate ? new Date(resetDate * 1000).toLocaleString('fa-IR', { weekday: 'long', hour: '2-digit', minute: '2-digit' }) : 'هفته آینده'; |
|
|
if (mode === 'feature') { |
|
|
return ( |
|
|
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-2 sm:p-4"> |
|
|
<div className="absolute inset-0 bg-black/80 backdrop-blur-xl transition-opacity animate-[fadeIn_0.5s_ease-out]" onClick={onClose}></div> |
|
|
<div className="relative bg-[#0f172a] rounded-[2rem] sm:rounded-[2.5rem] w-full max-w-[380px] max-h-[92vh] flex flex-col p-5 sm:p-8 shadow-[0_0_50px_rgba(139,92,246,0.3)] animate-[scaleIn_0.5s_cubic-bezier(0.34,1.56,0.64,1)] overflow-hidden border border-white/10"> |
|
|
<div className="relative z-10 overflow-y-auto custom-scrollbar pr-1 flex flex-col items-center"> |
|
|
<h3 className="text-lg sm:text-xl font-black text-white mb-3 sm:mb-4 leading-tight text-center">استودیوی اختصاصی خودت رو بساز</h3> |
|
|
<button onClick={onUpgrade} className="w-full py-3.5 sm:py-4 rounded-xl sm:rounded-2xl font-black text-sm sm:text-base text-gray-900 bg-gradient-to-r from-amber-300 via-yellow-400 to-amber-600 hover:from-amber-400 hover:to-amber-700 transition-all flex items-center justify-center gap-2 group"><span>تهیه اشتراک برنامه</span></button> |
|
|
<button onClick={onClose} className="w-full py-2 rounded-lg font-bold text-[11px] sm:text-xs text-gray-500 hover:text-white transition-colors">فعلاً نه، برمیگردم</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
return ( |
|
|
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4"> |
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-md transition-opacity animate-[fadeIn_0.5s_ease-out]" onClick={onClose}></div> |
|
|
<div className="relative bg-white rounded-[2rem] w-full max-w-[380px] p-8 shadow-2xl animate-[scaleIn_0.4s_cubic-bezier(0.34,1.56,0.64,1)] overflow-hidden text-center border-4 border-amber-100"> |
|
|
<h3 className="text-2xl font-black text-gray-800 mb-2">اعتبار شما تمام شد!</h3> |
|
|
<div className="bg-amber-50 rounded-xl p-3 border border-amber-100 mb-6"><p className="text-gray-600 text-[11px] leading-relaxed">زمان شارژ مجدد: {resetDateStr}</p></div> |
|
|
<div className="space-y-3"> |
|
|
<button onClick={onUpgrade} className="w-full py-4 rounded-xl font-bold text-base text-gray-900 bg-gradient-to-r from-amber-300 via-yellow-400 to-amber-500 hover:from-amber-400 hover:to-amber-600 transition-all">ارتقا به نسخه نامحدود</button> |
|
|
<button onClick={onClose} className="w-full py-3 rounded-xl font-bold text-sm text-gray-500 hover:bg-gray-50 transition-colors">متوجه شدم، فعلا نه</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const DeleteModal = ({ isOpen, type = 'single', title, message, onClose, onConfirm }) => { |
|
|
if (!isOpen) return null; |
|
|
return ( |
|
|
<div className="fixed inset-0 z-[110000] flex items-center justify-center p-4"> |
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity animate-[fadeIn_0.3s_ease-out]" onClick={onClose}></div> |
|
|
<div className="relative bg-white rounded-[2.5rem] w-full max-w-[320px] p-8 shadow-2xl animate-[scaleIn_0.4s_cubic-bezier(0.175,0.885,0.32,1.275)] overflow-hidden text-center mx-auto border border-gray-100"> |
|
|
<h3 className="text-xl font-black text-gray-800 mb-3">{title || (type === 'all' ? 'پاکسازی کل تاریخچه' : 'حذف فایل')}</h3> |
|
|
<p className="text-gray-500 text-sm mb-8 leading-relaxed px-2 font-medium">{message || (type === 'all' ? 'آیا مطمئن هستید؟ تمام سوابق حذف میشوند.' : 'آیا از حذف این فایل اطمینان دارید؟')}</p> |
|
|
<div className="flex gap-3"> |
|
|
<button onClick={onClose} className="flex-1 py-4 rounded-2xl font-bold text-sm text-gray-500 bg-gray-100 hover:bg-gray-200 transition-all active:scale-95">انصراف</button> |
|
|
<button onClick={onConfirm} className="flex-1 py-4 rounded-2xl font-bold text-sm text-white bg-gradient-to-r from-red-500 to-rose-600 hover:from-red-600 hover:to-rose-700 shadow-lg shadow-red-200 transition-all transform active:scale-95">حذف کن</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const FlyToHistory = ({ onComplete, targetRef, mode }) => { |
|
|
const [style, setStyle] = useState({ top: '50%', left: '50%', transform: 'translate(-50%, -50%) scale(1.5)', opacity: 0 }); |
|
|
const isVoice = mode === 'vc'; |
|
|
useEffect(() => { |
|
|
requestAnimationFrame(() => setStyle(prev => ({ ...prev, opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }))); |
|
|
if (!targetRef.current) { setTimeout(onComplete, 800); return; } |
|
|
const rect = targetRef.current.getBoundingClientRect(); |
|
|
setTimeout(() => setStyle({ top: `${rect.top + rect.height / 2}px`, left: `${rect.left + rect.width / 2}px`, transform: 'translate(-50%, -50%) scale(0.1) rotate(-180deg)', opacity: 0.1 }), 250); |
|
|
setTimeout(onComplete, 1000); |
|
|
}, [targetRef, onComplete]); |
|
|
return ( |
|
|
<> |
|
|
<div style={{ ...style, transition: 'all 0.75s cubic-bezier(0.175, 0.885, 0.32, 1.275)', position: 'fixed', zIndex: 100 }} className="pointer-events-none flex items-center justify-center will-change-transform"> |
|
|
<div className={`relative w-20 h-20 bg-gradient-to-br ${isVoice ? 'from-blue-600 via-indigo-500 to-purple-600' : 'from-teal-400 via-emerald-500 to-cyan-600'} rounded-2xl flex items-center justify-center z-20 border-2 border-white/40 backdrop-blur-sm`}> |
|
|
<i className={`fas ${isVoice ? 'fa-microphone-lines' : 'fa-keyboard'} text-3xl text-white drop-shadow-md`}></i> |
|
|
</div> |
|
|
</div> |
|
|
<div className="fixed inset-0 bg-white/30 z-[90] animate-[fadeOut_0.5s_ease-out] pointer-events-none"></div> |
|
|
</> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const App = () => { |
|
|
const [jobs, setJobs] = useState({}); |
|
|
const [customModels, setCustomModels] = useState([]); |
|
|
const [activeJobId, setActiveJobId] = useState(null); |
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
const [activeTab, setActiveTab] = useState('home'); |
|
|
const [isModalOpen, setIsModalOpen] = useState(false); |
|
|
const [isCreateModelOpen, setIsCreateModelOpen] = useState(false); |
|
|
const [selectedModel, setSelectedModel] = useState(null); |
|
|
const [editingModel, setEditingModel] = useState(null); |
|
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false); |
|
|
const [deleteTarget, setDeleteTarget] = useState(null); |
|
|
const [deleteMode, setDeleteMode] = useState('job'); |
|
|
const [subscriptionStatus, setSubscriptionStatus] = useState('free'); |
|
|
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); |
|
|
const [upgradeModalMode, setUpgradeModalMode] = useState('limit'); |
|
|
const [resetDate, setResetDate] = useState(null); |
|
|
const [userFingerprint, setUserFingerprint] = useState(null); |
|
|
const [showFlyAnimation, setShowFlyAnimation] = useState(false); |
|
|
const [lastJobMode, setLastJobMode] = useState('vc'); |
|
|
const activeJobPayloads = useRef({}); |
|
|
const historyBtnRef = useRef(null); |
|
|
const PREMIUM_PAGE_ID = '1149636'; |
|
|
const FULL_UPGRADE_URL = "#/nav/online/news/getSingle/1149636/eyJpdiI6IkVWcVZ2RFRERVhyaXBxaGtVSHpFK2c9PSIsInZhbHVlIjoiMmJWVllYNnhDNDJjbU96Q0Z5RHdzeE9HWjkrWTVNVzVFTzl3bFlUTzVsWHVTU1pCMWRteUJFeUZ6RVBYUks2VSIsIm1hYyI6IjU3Y2ZjM2IyZDEyNjY4ZTQ3NDgyM2JkMzE0Zjg4Y2ZmZDc5OWQyNTRmYzM4MzBjY2M5ZTNmMmViZDE3N2U5YmQiLCJ0YWciOiIifQ==/20934991"; |
|
|
|
|
|
useEffect(() => { |
|
|
const initUser = async () => { |
|
|
const fp = await getBrowserFingerprint(); |
|
|
setUserFingerprint(fp); |
|
|
window.parent.postMessage({ type: 'REQUEST_USER_STATUS' }, '*'); |
|
|
}; |
|
|
initUser(); |
|
|
const handleMessage = (event) => { |
|
|
if (event.data?.type === 'USER_STATUS_RESPONSE') { |
|
|
try { |
|
|
if (event.data.error || !event.data.payload) { setSubscriptionStatus('free'); return; } |
|
|
const userObject = JSON.parse(event.data.payload); |
|
|
const isPaid = userObject && userObject.isLogin && userObject.accessible_pages && (userObject.accessible_pages.includes(PREMIUM_PAGE_ID) || userObject.accessible_pages.includes(parseInt(PREMIUM_PAGE_ID))); |
|
|
setSubscriptionStatus(isPaid ? 'paid' : 'free'); |
|
|
} catch (e) { setSubscriptionStatus('free'); } |
|
|
} |
|
|
}; |
|
|
window.addEventListener('message', handleMessage); |
|
|
return () => window.removeEventListener('message', handleMessage); |
|
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
const loadData = async () => { |
|
|
try { |
|
|
const [storedJobs, storedModels] = await Promise.all([getAllJobs(), getAllCustomModels()]); |
|
|
const jobsMap = {}; |
|
|
storedJobs.forEach(job => { jobsMap[job.job_id] = job; }); |
|
|
setJobs(jobsMap); |
|
|
setCustomModels(storedModels); |
|
|
} catch (err) { console.error("Failed to load data from DB", err); } |
|
|
}; |
|
|
loadData(); |
|
|
}, []); |
|
|
|
|
|
const executeJobProcess = async (tempId, data) => { |
|
|
try { |
|
|
let actualRefFile = data.refFile; |
|
|
const isLegacy = data.model?.type === 'legacy_rvc'; |
|
|
|
|
|
if (!isLegacy && !actualRefFile && data.refFileUrl) { |
|
|
actualRefFile = await fetchRefAudioAsFile(data.refFileUrl, 'ref.wav'); |
|
|
} |
|
|
|
|
|
const finalRef = actualRefFile || new Blob([''], { type: 'audio/wav' }); |
|
|
|
|
|
const resultJob = await processUnifiedJob( |
|
|
data.sourceFile, data.text, finalRef, data.model || null, data.mode, data.pitch, |
|
|
(msg, progress) => { |
|
|
setJobs(prev => { |
|
|
const job = prev[tempId]; if (!job) return prev; |
|
|
return { ...prev, [tempId]: { ...job, statusMessage: msg, progress: progress !== undefined ? progress : job.progress } }; |
|
|
}); |
|
|
}, |
|
|
data.type |
|
|
); |
|
|
|
|
|
const existingJob = jobs[tempId] || {}; |
|
|
const finalJobData = { |
|
|
...existingJob, |
|
|
...resultJob, |
|
|
type: existingJob.type || resultJob.type || data.type, |
|
|
modelName: existingJob.modelName || resultJob.modelName || data.model?.name, |
|
|
modelImage: existingJob.modelImage || resultJob.modelImage || (typeof data.model?.image === 'string' ? data.model.image : undefined), |
|
|
mode: existingJob.mode || resultJob.mode || data.mode |
|
|
}; |
|
|
|
|
|
await saveJob(finalJobData); |
|
|
await deleteJob(tempId); |
|
|
|
|
|
setJobs(prev => { |
|
|
const nextJobs = { ...prev }; |
|
|
delete nextJobs[tempId]; |
|
|
nextJobs[finalJobData.job_id] = finalJobData; |
|
|
if (finalJobData.status === 'completed' || finalJobData.status === 'failed') delete activeJobPayloads.current[tempId]; |
|
|
return nextJobs; |
|
|
}); |
|
|
} catch (error) { |
|
|
const payload = activeJobPayloads.current[tempId]; |
|
|
if (payload && payload.retryCount < 3) { |
|
|
payload.retryCount += 1; |
|
|
setJobs(prev => { |
|
|
const job = prev[tempId]; if (!job) return prev; |
|
|
const updated = { ...job, status: 'started', statusMessage: `تلاش مجدد (${payload.retryCount}/3)...`, retryCount: payload.retryCount }; |
|
|
saveJob(updated); return { ...prev, [tempId]: updated }; |
|
|
}); |
|
|
setTimeout(() => executeJobProcess(tempId, payload), 2000); |
|
|
} else { |
|
|
delete activeJobPayloads.current[tempId]; |
|
|
setJobs(prev => { |
|
|
const job = prev[tempId]; if (!job) return prev; |
|
|
const failedJob = { ...job, status: 'failed', statusMessage: 'خطا: ' + error.message }; |
|
|
saveJob(failedJob); return { ...prev, [tempId]: failedJob }; |
|
|
}); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
useEffect(() => { |
|
|
const pollActiveJobs = async () => { |
|
|
const activeJobsList = Object.values(jobs); |
|
|
const jobsToPoll = activeJobsList.filter(job => (job.status === 'started' || job.status === 'processing') && !job.downloadUrl && !job.job_id.startsWith('temp_')); |
|
|
if (jobsToPoll.length === 0) return; |
|
|
const updates = {}; |
|
|
let hasChanges = false; |
|
|
await Promise.all(jobsToPoll.map(async (job) => { |
|
|
try { |
|
|
const statusRes = await checkJobStatus(job); |
|
|
if (statusRes.status === 'completed' || statusRes.status === 'failed' || statusRes.status === 'error') { |
|
|
const isSuccess = statusRes.status === 'completed'; |
|
|
const updatedJob = { |
|
|
...job, status: isSuccess ? 'completed' : 'failed', filename: statusRes.filename, downloadUrl: statusRes.downloadUrl, |
|
|
progress: isSuccess ? 100 : job.progress, statusMessage: statusRes.detail || (isSuccess ? 'تکمیل شد' : 'خطا در پردازش') |
|
|
}; |
|
|
updates[job.job_id] = updatedJob; hasChanges = true; saveJob(updatedJob); |
|
|
} else { |
|
|
let shouldUpdate = false; |
|
|
let newChunks = job.chunks; |
|
|
if (statusRes.chunks && JSON.stringify(statusRes.chunks) !== JSON.stringify(job.chunks)) { newChunks = statusRes.chunks; shouldUpdate = true; } |
|
|
if ((statusRes.detail && statusRes.detail !== job.statusMessage) || (statusRes.progress && statusRes.progress > (job.progress || 0))) shouldUpdate = true; |
|
|
if (shouldUpdate) { updates[job.job_id] = { ...job, progress: statusRes.progress || job.progress, statusMessage: statusRes.detail || job.statusMessage, chunks: newChunks }; hasChanges = true; } |
|
|
} |
|
|
} catch (err) { console.error(err); } |
|
|
})); |
|
|
if (hasChanges) setJobs(prev => ({ ...prev, ...updates })); |
|
|
}; |
|
|
const interval = setInterval(pollActiveJobs, 3000); |
|
|
return () => clearInterval(interval); |
|
|
}, [jobs]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const pollEnhancementJobs = async () => { |
|
|
const enhanceJobsList = Object.values(jobs).filter(job => job.enhancement && job.enhancement.status === 'processing'); |
|
|
if (enhanceJobsList.length === 0) return; |
|
|
const updates = {}; |
|
|
let hasChanges = false; |
|
|
await Promise.all(enhanceJobsList.map(async (job) => { |
|
|
const enh = job.enhancement; |
|
|
try { |
|
|
const statusRes = await checkEnhancementStatus(enh.metadata); |
|
|
let nextProgress = enh.progress || 5; |
|
|
if (statusRes.status === 'completed') nextProgress = 100; |
|
|
else nextProgress = Math.min(95, nextProgress + 1); |
|
|
|
|
|
if (statusRes.status !== enh.status || nextProgress !== enh.progress) { |
|
|
const updatedJob = { ...job, enhancement: { ...enh, status: statusRes.status, progress: nextProgress, filename: statusRes.filename || enh.filename } }; |
|
|
updates[job.job_id] = updatedJob; |
|
|
hasChanges = true; |
|
|
saveJob(updatedJob); |
|
|
} |
|
|
} catch (err) { console.error(err); } |
|
|
})); |
|
|
if (hasChanges) setJobs(prev => ({ ...prev, ...updates })); |
|
|
}; |
|
|
const interval = setInterval(pollEnhancementJobs, 3000); |
|
|
return () => clearInterval(interval); |
|
|
}, [jobs]); |
|
|
|
|
|
const startJob = async (data) => { |
|
|
const isCustomModel = data.type === 'custom' || data.model?.isCustom; |
|
|
if (subscriptionStatus === 'free') { |
|
|
if (isCustomModel) { setUpgradeModalMode('feature'); setUpgradeModalOpen(true); return; } |
|
|
if (userFingerprint) { |
|
|
const check = await checkCredit(userFingerprint, data.model?.id || 'standard_vc'); |
|
|
if (check.limit_reached) { setResetDate(check.reset_timestamp); setUpgradeModalMode('limit'); setUpgradeModalOpen(true); return; } |
|
|
useCredit(userFingerprint, data.model?.id || 'standard_vc'); |
|
|
} |
|
|
} |
|
|
const tempJobId = `temp_${Date.now()}`; |
|
|
const initialJob = { |
|
|
job_id: tempJobId, status: 'started', progress: 0, statusMessage: 'ارسال درخواست...', total_chunks: 1, chunks: [], |
|
|
date: new Date().toLocaleString('fa-IR'), timestamp: Date.now(), |
|
|
type: data.type, mode: data.mode, modelName: data.model?.name, modelImage: typeof data.model?.image === 'string' ? data.model.image : undefined |
|
|
}; |
|
|
setJobs(prev => ({ ...prev, [tempJobId]: initialJob })); |
|
|
await saveJob(initialJob); |
|
|
setIsModalOpen(false); |
|
|
setLastJobMode(data.mode); |
|
|
setShowFlyAnimation(true); |
|
|
activeJobPayloads.current[tempJobId] = { ...data, retryCount: 0 }; |
|
|
executeJobProcess(tempJobId, data); |
|
|
}; |
|
|
|
|
|
const handleEnhance = async (job) => { |
|
|
setJobs(prev => ({ ...prev, [job.job_id]: { ...job, enhancement: { status: 'processing', progress: 5 } } })); |
|
|
try { |
|
|
const audioFile = await fetchRefAudioAsFile(job.downloadUrl || `https://ezmary-sada.hf.space/download/${job.filename}`, 'orig.wav'); |
|
|
const response = await uploadEnhancementJob(audioFile); |
|
|
const processingJob = { ...job, enhancement: { status: 'processing', job_id: response.job_id, progress: 5, metadata: response } }; |
|
|
setJobs(prev => ({ ...prev, [job.job_id]: processingJob })); |
|
|
saveJob(processingJob); |
|
|
} catch (err) { setJobs(prev => ({ ...prev, [job.job_id]: { ...job, enhancement: { status: 'failed', progress: 0 } } })); } |
|
|
}; |
|
|
|
|
|
const handleSaveModel = async (model) => { |
|
|
try { |
|
|
await saveCustomModel(model); |
|
|
setCustomModels(await getAllCustomModels()); |
|
|
setIsCreateModelOpen(false); |
|
|
setEditingModel(null); |
|
|
} catch (error) { |
|
|
console.error("Save model error:", error); |
|
|
alert("خطا در ذخیره مدل. ممکن است حجم فایل زیاد باشد یا حافظه مرورگر پر شده باشد."); |
|
|
} |
|
|
}; |
|
|
|
|
|
const confirmDelete = async () => { |
|
|
if (deleteMode === 'job') { |
|
|
if (deleteTarget === 'all') { setJobs({}); await clearAllJobs(); } |
|
|
else if (typeof deleteTarget === 'string') { setJobs(prev => { const next = { ...prev }; delete next[deleteTarget]; return next; }); await deleteJob(deleteTarget); } |
|
|
} else if (deleteMode === 'model' && typeof deleteTarget === 'string') { |
|
|
await deleteCustomModel(deleteTarget); |
|
|
setCustomModels(await getAllCustomModels()); |
|
|
setEditingModel(null); |
|
|
} |
|
|
setDeleteModalOpen(false); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="min-h-screen bg-[#f8fafc] text-gray-800 font-vazir pb-24 overflow-x-hidden"> |
|
|
{showFlyAnimation && <FlyToHistory targetRef={historyBtnRef} onComplete={() => setShowFlyAnimation(false)} mode={lastJobMode} />} |
|
|
<div className="max-w-lg mx-auto p-4 pt-8"> |
|
|
<div className="text-center mb-8 relative animate-[fadeIn_0.8s_ease-out]"> |
|
|
<div className="w-20 h-20 mx-auto mb-5 bg-white rounded-3xl flex items-center justify-center shadow-xl shadow-indigo-500/10 rotate-3 border border-indigo-50 relative group"> |
|
|
<div className="relative bg-gradient-to-tr from-primary to-secondary w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-lg shadow-primary/40"><i className="fas fa-wave-square text-2xl animate-[pulse_3s_infinite]"></i></div> |
|
|
</div> |
|
|
<h1 className="text-2xl font-black text-gray-800 mb-3">تغییر صدا با <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">هوش مصنوعی آلفا</span></h1> |
|
|
<p className="text-gray-500 text-[13px] font-medium mb-6 max-w-[280px] mx-auto leading-relaxed">استودیو پیشرفته تبدیل متن به گفتار و تغییر صدای خوانندگان و مشاهیر</p> |
|
|
<div className={`inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs font-bold transition-all ${subscriptionStatus === 'paid' ? 'bg-amber-300 shadow-yellow-500/20' : 'bg-white border border-gray-200'}`}> |
|
|
{subscriptionStatus === 'paid' ? <><i className="fas fa-crown"></i><span>نسخه نامحدود</span></> : <><i className="fas fa-leaf text-green-500"></i><span>نسخه رایگان</span></>} |
|
|
</div> |
|
|
</div> |
|
|
{activeTab === 'home' && <ModelGrid customModels={customModels} onSelectModel={(m) => { setSelectedModel(m); setIsModalOpen(true); }} onOpenCustom={() => setActiveTab('custom')} onAddNewModel={() => { if(subscriptionStatus==='paid') setIsCreateModelOpen(true); else { setUpgradeModalMode('feature'); setUpgradeModalOpen(true); } }} onEditModel={(m) => { setEditingModel(m); setIsCreateModelOpen(true); }} />} |
|
|
{activeTab === 'custom' && <CustomVoice onStart={(src, ref) => startJob({ sourceFile: src, refFile: ref, type: 'custom', mode: 'vc', pitch: 0 })} isLoading={isLoading} />} |
|
|
{activeTab === 'history' && <History jobs={Object.values(jobs)} onClearAll={() => { setDeleteMode('job'); setDeleteTarget('all'); setDeleteModalOpen(true); }} onDeleteSingle={(id) => { setDeleteMode('job'); setDeleteTarget(id); setDeleteModalOpen(true); }} onSelect={(j) => setActiveJobId(j.job_id)} onEnhance={handleEnhance} />} |
|
|
</div> |
|
|
<ModelModal isOpen={isModalOpen} model={selectedModel} onClose={() => setIsModalOpen(false)} onConfirm={(data) => { |
|
|
startJob({ |
|
|
sourceFile: data.file, text: data.text, |
|
|
refFile: typeof selectedModel.refFile !== 'string' ? selectedModel.refFile : undefined, |
|
|
refFileUrl: typeof selectedModel.refFile === 'string' ? selectedModel.refFile : undefined, |
|
|
type: 'model', mode: data.mode, model: selectedModel, pitch: data.pitch |
|
|
}); |
|
|
}} isLoading={isLoading} /> |
|
|
<CreateModelModal isOpen={isCreateModelOpen} editingModel={editingModel} onClose={() => setIsCreateModelOpen(false)} onSave={handleSaveModel} onDelete={(id) => { setDeleteMode('model'); setDeleteTarget(id); setDeleteModalOpen(true); setIsCreateModelOpen(false); }} /> |
|
|
<DeleteModal isOpen={deleteModalOpen} type={deleteTarget === 'all' ? 'all' : 'single'} title={deleteMode === 'model' ? 'حذف فایل' : undefined} message={deleteMode === 'model' ? 'آیا از حذف این مدل اختصاصی اطمینان دارید؟' : undefined} onClose={() => setDeleteModalOpen(false)} onConfirm={confirmDelete} /> |
|
|
<UpgradeModal isOpen={upgradeModalOpen} mode={upgradeModalMode} onClose={() => setUpgradeModalOpen(false)} onUpgrade={() => window.parent.postMessage({ type: 'NAVIGATE_TO_PREMIUM', payload: { url: FULL_UPGRADE_URL } }, '*')} resetDate={resetDate || undefined} /> |
|
|
<BottomNav activeTab={activeTab} onChange={setActiveTab} badgeCount={(Object.values(jobs)).filter(j=>j.status==='processing'||j.status==='started').length} historyRef={historyBtnRef} /> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
const rootElement = document.getElementById('root'); |
|
|
const root = ReactDOM.createRoot(rootElement); |
|
|
root.render(<App />); |
|
|
</script> |
|
|
</body> |
|
|
</html> |