Spaces:
Build error
Build error
Upload folder using huggingface_hub
Browse files- .gitignore +1 -0
- local_setup_guide.md +48 -0
- main.py +47 -5
- pyproject.toml +2 -0
- services/ai_pipeline.py +30 -0
- static/styles.css +52 -0
- templates/index.html +306 -0
- templates/tts.html +308 -0
- uv.lock +15 -7
- worker.py +48 -1
.gitignore
CHANGED
|
@@ -161,3 +161,4 @@ tmp/
|
|
| 161 |
temp/
|
| 162 |
outputs/
|
| 163 |
*.pen
|
|
|
|
|
|
| 161 |
temp/
|
| 162 |
outputs/
|
| 163 |
*.pen
|
| 164 |
+
uv_tree.txt
|
local_setup_guide.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hướng dẫn Chạy Local - UI-VieNeu Backend
|
| 2 |
+
|
| 3 |
+
Tài liệu này ghi lại toàn bộ các bước để thiết lập và chạy dự án Video Subtitle & AI Voiceover ở môi trường máy tính cá nhân (Windows).
|
| 4 |
+
|
| 5 |
+
## 1. Cài đặt Công cụ Quản lý (uv)
|
| 6 |
+
Nếu máy bạn chưa có `uv`, hãy mở PowerShell và chạy lệnh sau:
|
| 7 |
+
```powershell
|
| 8 |
+
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
## 2. Thiết lập Môi trường và Thư viện
|
| 12 |
+
Di chuyển vào thư mục dự án và cài bộ thư viện:
|
| 13 |
+
```powershell
|
| 14 |
+
# Cài đặt toàn bộ thư viện từ pyproject.toml vào thư mục .venv
|
| 15 |
+
uv sync
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
## 3. Khởi động Máy chủ Redis (Memurai)
|
| 19 |
+
Dự án cần Redis để làm "bưu điện" chuyển tin nhắn cho Celery.
|
| 20 |
+
- **Bước 1:** Mở **VSCode bằng quyền Administrator**.
|
| 21 |
+
- **Bước 2:** Chạy lệnh bật dịch vụ:
|
| 22 |
+
```powershell
|
| 23 |
+
Start-Service Memurai
|
| 24 |
+
```
|
| 25 |
+
*(Kiểm tra màu xanh trong RedisInsight để chắc chắn đã bật thành công)*
|
| 26 |
+
|
| 27 |
+
## 4. Chạy Hệ thống (Cần mở 2 Terminal song song)
|
| 28 |
+
|
| 29 |
+
### Terminal 1: Chạy Celery Worker (Xử lý AI & FFmpeg)
|
| 30 |
+
Sử dụng `uv run` để đảm bảo dùng đúng thư viện trong môi trường ảo:
|
| 31 |
+
```powershell
|
| 32 |
+
uv run celery -A worker worker --loglevel=info -P solo
|
| 33 |
+
```
|
| 34 |
+
> **Lưu ý:** Tham số `-P solo` là bắt buộc để Celery có thể chạy được trên hệ điều hành Windows.
|
| 35 |
+
|
| 36 |
+
### Terminal 2: Chạy FastAPI Server (Cổng kết nối API)
|
| 37 |
+
```powershell
|
| 38 |
+
uv run uvicorn main:app --reload
|
| 39 |
+
```
|
| 40 |
+
- API sẽ chạy tại: `http://127.0.0.1:8000`
|
| 41 |
+
- Tài liệu API (Swagger UI): `http://127.0.0.1:8000/docs`
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Các lỗi thường gặp và cách xử lý
|
| 46 |
+
1. **ModuleNotFoundError:** Hãy chắc chắn bạn luôn có chữ `uv run` ở đầu lệnh để nó nhận diện được thư viện trong `.venv`.
|
| 47 |
+
2. **ConnectionError (Redis):** Kiểm tra xem Memurai đã được Start chưa.
|
| 48 |
+
3. **Permission Denied:** Luôn chạy Terminal/VSCode với quyền Administrator khi can thiệp vào các Service như Memurai.
|
main.py
CHANGED
|
@@ -1,11 +1,20 @@
|
|
| 1 |
import os
|
| 2 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from pydantic import BaseModel
|
| 4 |
from supabase import create_client, Client
|
| 5 |
-
from worker import render_video_task
|
| 6 |
|
| 7 |
app = FastAPI(title="VieNeu Video AI processing API")
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
# Setup Supabase
|
| 10 |
SUPABASE_URL = os.getenv("SUPABASE_URL", "https://your-project.supabase.co")
|
| 11 |
SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "your-service-key")
|
|
@@ -15,9 +24,13 @@ class RenderJobRequest(BaseModel):
|
|
| 15 |
script_text: str
|
| 16 |
voice_preset_id: str = "default"
|
| 17 |
|
| 18 |
-
@app.get("/")
|
| 19 |
-
def read_root():
|
| 20 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
@app.post("/api/v1/jobs/submit")
|
| 23 |
async def submit_job(
|
|
@@ -53,6 +66,35 @@ async def submit_job(
|
|
| 53 |
|
| 54 |
return {"job_id": job_id, "status": "processing_queued"}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
@app.get("/api/v1/jobs/{job_id}")
|
| 57 |
async def get_job_status(job_id: str):
|
| 58 |
response = supabase.table("video_jobs").select("*").eq("id", job_id).execute()
|
|
|
|
| 1 |
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
load_dotenv(override=True)
|
| 4 |
+
from fastapi import FastAPI, UploadFile, File, Form, Request
|
| 5 |
+
from fastapi.responses import HTMLResponse
|
| 6 |
+
from fastapi.staticfiles import StaticFiles
|
| 7 |
+
from fastapi.templating import Jinja2Templates
|
| 8 |
from pydantic import BaseModel
|
| 9 |
from supabase import create_client, Client
|
| 10 |
+
from worker import render_video_task, generate_tts_task
|
| 11 |
|
| 12 |
app = FastAPI(title="VieNeu Video AI processing API")
|
| 13 |
|
| 14 |
+
# Mount thư mục tĩnh và giao diện HTML
|
| 15 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 16 |
+
templates = Jinja2Templates(directory="templates")
|
| 17 |
+
|
| 18 |
# Setup Supabase
|
| 19 |
SUPABASE_URL = os.getenv("SUPABASE_URL", "https://your-project.supabase.co")
|
| 20 |
SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "your-service-key")
|
|
|
|
| 24 |
script_text: str
|
| 25 |
voice_preset_id: str = "default"
|
| 26 |
|
| 27 |
+
@app.get("/", response_class=HTMLResponse)
|
| 28 |
+
async def read_root(request: Request):
|
| 29 |
+
return templates.TemplateResponse(request=request, name="index.html")
|
| 30 |
+
|
| 31 |
+
@app.get("/tts", response_class=HTMLResponse)
|
| 32 |
+
async def read_tts(request: Request):
|
| 33 |
+
return templates.TemplateResponse(request=request, name="tts.html")
|
| 34 |
|
| 35 |
@app.post("/api/v1/jobs/submit")
|
| 36 |
async def submit_job(
|
|
|
|
| 66 |
|
| 67 |
return {"job_id": job_id, "status": "processing_queued"}
|
| 68 |
|
| 69 |
+
@app.post("/api/v1/tts/generate")
|
| 70 |
+
async def submit_tts_job(
|
| 71 |
+
script: str = Form(...),
|
| 72 |
+
temperature: float = Form(0.5),
|
| 73 |
+
voice_preset: str = Form("default"),
|
| 74 |
+
ref_audio: UploadFile = File(None)
|
| 75 |
+
):
|
| 76 |
+
"""
|
| 77 |
+
Submits a pure Text-To-Speech task to Celery.
|
| 78 |
+
"""
|
| 79 |
+
ref_audio_path = None
|
| 80 |
+
if ref_audio:
|
| 81 |
+
ref_audio_bytes = await ref_audio.read()
|
| 82 |
+
ref_audio_path = f"references/{ref_audio.filename}"
|
| 83 |
+
supabase.storage.from_("content").upload(path=ref_audio_path, file=ref_audio_bytes)
|
| 84 |
+
|
| 85 |
+
# Note: Using generic "video_jobs" table to track TTS jobs as well to save setup time.
|
| 86 |
+
db_resp = supabase.table("video_jobs").insert({
|
| 87 |
+
"status": "pending",
|
| 88 |
+
"script": script,
|
| 89 |
+
"raw_video_path": "audio_only"
|
| 90 |
+
}).execute()
|
| 91 |
+
|
| 92 |
+
job_id = db_resp.data[0]["id"] if db_resp.data else "unknown"
|
| 93 |
+
|
| 94 |
+
generate_tts_task.delay(job_id, script, voice_preset, temperature, ref_audio_path)
|
| 95 |
+
|
| 96 |
+
return {"job_id": job_id, "status": "processing_queued"}
|
| 97 |
+
|
| 98 |
@app.get("/api/v1/jobs/{job_id}")
|
| 99 |
async def get_job_status(job_id: str):
|
| 100 |
response = supabase.table("video_jobs").select("*").eq("id", job_id).execute()
|
pyproject.toml
CHANGED
|
@@ -9,7 +9,9 @@ dependencies = [
|
|
| 9 |
"fastapi>=0.136.0",
|
| 10 |
"faster-whisper>=1.2.1",
|
| 11 |
"ffmpeg-python>=0.2.0",
|
|
|
|
| 12 |
"pydantic>=2.13.2",
|
|
|
|
| 13 |
"python-multipart>=0.0.26",
|
| 14 |
"redis>=7.4.0",
|
| 15 |
"supabase>=2.28.3",
|
|
|
|
| 9 |
"fastapi>=0.136.0",
|
| 10 |
"faster-whisper>=1.2.1",
|
| 11 |
"ffmpeg-python>=0.2.0",
|
| 12 |
+
"jinja2>=3.1.6",
|
| 13 |
"pydantic>=2.13.2",
|
| 14 |
+
"python-dotenv>=1.2.2",
|
| 15 |
"python-multipart>=0.0.26",
|
| 16 |
"redis>=7.4.0",
|
| 17 |
"supabase>=2.28.3",
|
services/ai_pipeline.py
CHANGED
|
@@ -67,6 +67,36 @@ def process_video_pipeline(tmpdir: str, video_file: str, script: str, ref_audio:
|
|
| 67 |
|
| 68 |
return output_video
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
def generate_ass_file(words_data: list, dest_path: str):
|
| 71 |
"""
|
| 72 |
Crafts an advanced SubStation Alpha file for Karaoke effects.
|
|
|
|
| 67 |
|
| 68 |
return output_video
|
| 69 |
|
| 70 |
+
def generate_tts_only(tmpdir: str, script: str, ref_audio: str = None, temperature: float = 0.5) -> str:
|
| 71 |
+
"""
|
| 72 |
+
Standalone function to just generate TTS audio.
|
| 73 |
+
"""
|
| 74 |
+
tts_audio_path = os.path.join(tmpdir, "tts_voiceover.wav")
|
| 75 |
+
|
| 76 |
+
# Passing keyword arguments; if underlying model doesn't strictly accept temperature,
|
| 77 |
+
# python handles **kwargs flexibly if written cleanly in wrappers.
|
| 78 |
+
# To avoid crashing, we'll try to pass it to `infer`. If Vieneu object restricts kwargs tightly,
|
| 79 |
+
# we can trap the type error and fallback to not using temperature.
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
if ref_audio:
|
| 83 |
+
my_voice = tts.encode_reference(ref_audio)
|
| 84 |
+
audio_array = tts.infer(text=script, voice=my_voice, temperature=temperature)
|
| 85 |
+
else:
|
| 86 |
+
# We assume default voices can be tuned with temperature
|
| 87 |
+
audio_array = tts.infer(text=script, temperature=temperature)
|
| 88 |
+
except TypeError:
|
| 89 |
+
# Fallback if Vieneu.infer doesn't support 'temperature'
|
| 90 |
+
print("Warning: Vieneu.infer doesn't support temperature. Ignoring it.")
|
| 91 |
+
if ref_audio:
|
| 92 |
+
my_voice = tts.encode_reference(ref_audio)
|
| 93 |
+
audio_array = tts.infer(text=script, voice=my_voice)
|
| 94 |
+
else:
|
| 95 |
+
audio_array = tts.infer(text=script)
|
| 96 |
+
|
| 97 |
+
tts.save(audio_array, tts_audio_path)
|
| 98 |
+
return tts_audio_path
|
| 99 |
+
|
| 100 |
def generate_ass_file(words_data: list, dest_path: str):
|
| 101 |
"""
|
| 102 |
Crafts an advanced SubStation Alpha file for Karaoke effects.
|
static/styles.css
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 2 |
+
|
| 3 |
+
body {
|
| 4 |
+
font-family: 'Inter', sans-serif;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.glass-panel {
|
| 8 |
+
background: rgba(31, 41, 55, 0.7);
|
| 9 |
+
backdrop-filter: blur(16px);
|
| 10 |
+
-webkit-backdrop-filter: blur(16px);
|
| 11 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.custom-bg {
|
| 15 |
+
background-image:
|
| 16 |
+
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
|
| 17 |
+
radial-gradient(at 100% 0%, rgba(168, 85, 247, 0.15) 0px, transparent 50%),
|
| 18 |
+
radial-gradient(at 100% 100%, rgba(236, 72, 153, 0.15) 0px, transparent 50%),
|
| 19 |
+
radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.15) 0px, transparent 50%);
|
| 20 |
+
background-color: #0f172a;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* Custom Scrollbar */
|
| 24 |
+
::-webkit-scrollbar {
|
| 25 |
+
width: 8px;
|
| 26 |
+
}
|
| 27 |
+
::-webkit-scrollbar-track {
|
| 28 |
+
background: #1e293b;
|
| 29 |
+
}
|
| 30 |
+
::-webkit-scrollbar-thumb {
|
| 31 |
+
background: #475569;
|
| 32 |
+
border-radius: 4px;
|
| 33 |
+
}
|
| 34 |
+
::-webkit-scrollbar-thumb:hover {
|
| 35 |
+
background: #64748b;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Spinner Animation */
|
| 39 |
+
.spinner {
|
| 40 |
+
border: 3px solid rgba(255,255,255,0.1);
|
| 41 |
+
border-radius: 50%;
|
| 42 |
+
border-top: 3px solid #8b5cf6;
|
| 43 |
+
width: 24px;
|
| 44 |
+
height: 24px;
|
| 45 |
+
-webkit-animation: spin 1s linear infinite; /* Safari */
|
| 46 |
+
animation: spin 1s linear infinite;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
@keyframes spin {
|
| 50 |
+
0% { transform: rotate(0deg); }
|
| 51 |
+
100% { transform: rotate(360deg); }
|
| 52 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi" class="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>UI-VieNeu | AI Video Engine</title>
|
| 7 |
+
<!-- Tailwind CSS CDN -->
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
<script>
|
| 10 |
+
tailwind.config = {
|
| 11 |
+
darkMode: 'class',
|
| 12 |
+
theme: {
|
| 13 |
+
extend: {
|
| 14 |
+
colors: {
|
| 15 |
+
primary: '#8b5cf6',
|
| 16 |
+
secondary: '#ec4899'
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
</script>
|
| 22 |
+
<!-- Custom Styles -->
|
| 23 |
+
<link rel="stylesheet" href="/static/styles.css">
|
| 24 |
+
<!-- Feather Icons -->
|
| 25 |
+
<script src="https://unpkg.com/feather-icons"></script>
|
| 26 |
+
</head>
|
| 27 |
+
<body class="text-slate-200 min-h-screen custom-bg selection:bg-primary selection:text-white">
|
| 28 |
+
|
| 29 |
+
<!-- Header -->
|
| 30 |
+
<header class="border-b border-slate-700/50 bg-slate-900/50 backdrop-blur-md sticky top-0 z-50">
|
| 31 |
+
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
| 32 |
+
<div class="flex items-center gap-3">
|
| 33 |
+
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-lg shadow-primary/20">
|
| 34 |
+
<i data-feather="video" class="text-white w-5 h-5"></i>
|
| 35 |
+
</div>
|
| 36 |
+
<h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-slate-400">
|
| 37 |
+
UI-VieNeu <span class="font-normal text-sm text-primary ml-1">v1.0</span>
|
| 38 |
+
</h1>
|
| 39 |
+
</div>
|
| 40 |
+
<a href="/docs" target="_blank" class="text-sm text-slate-400 hover:text-white transition-colors flex items-center gap-2">
|
| 41 |
+
<i data-feather="code" class="w-4 h-4"></i> API Docs
|
| 42 |
+
</a>
|
| 43 |
+
</div>
|
| 44 |
+
</header>
|
| 45 |
+
|
| 46 |
+
<!-- Main Content -->
|
| 47 |
+
<main class="max-w-6xl mx-auto px-6 py-10 grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 48 |
+
|
| 49 |
+
<!-- Left Column: Input Form -->
|
| 50 |
+
<div class="lg:col-span-5 space-y-6">
|
| 51 |
+
<div class="glass-panel rounded-2xl p-6 shadow-xl">
|
| 52 |
+
<h2 class="text-lg font-semibold mb-6 flex items-center gap-2 text-white">
|
| 53 |
+
<i data-feather="edit-3" class="w-5 h-5 text-primary"></i> Create New Video
|
| 54 |
+
</h2>
|
| 55 |
+
|
| 56 |
+
<form id="uploadForm" class="space-y-5">
|
| 57 |
+
|
| 58 |
+
<!-- Video Upload -->
|
| 59 |
+
<div>
|
| 60 |
+
<label class="block text-sm font-medium text-slate-300 mb-2">Raw Video (Background)</label>
|
| 61 |
+
<div class="relative group">
|
| 62 |
+
<input type="file" id="videoFile" accept="video/mp4,video/x-m4v,video/*" required
|
| 63 |
+
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
| 64 |
+
<div class="border-2 border-dashed border-slate-600 rounded-xl p-4 flex items-center justify-center gap-3 bg-slate-800/50 group-hover:bg-slate-800 transition-colors group-hover:border-primary/50">
|
| 65 |
+
<i data-feather="upload-cloud" class="w-6 h-6 text-slate-400 group-hover:text-primary transition-colors"></i>
|
| 66 |
+
<span id="videoFileName" class="text-sm text-slate-400">Select MP4 file...</span>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<!-- Voice Reference (Optional) -->
|
| 72 |
+
<div>
|
| 73 |
+
<label class="block text-sm font-medium text-slate-300 mb-2">Voice Reference Audio <span class="text-xs text-slate-500 font-normal">(Optional for cloning)</span></label>
|
| 74 |
+
<div class="relative group">
|
| 75 |
+
<input type="file" id="audioFile" accept="audio/*"
|
| 76 |
+
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
| 77 |
+
<div class="border border-slate-700 rounded-xl p-3 flex items-center gap-3 bg-slate-800/30 group-hover:bg-slate-800/80 transition-colors">
|
| 78 |
+
<i data-feather="mic" class="w-5 h-5 text-slate-500 group-hover:text-secondary transition-colors"></i>
|
| 79 |
+
<span id="audioFileName" class="text-sm text-slate-500">Select audio file...</span>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<!-- Script Input -->
|
| 85 |
+
<div>
|
| 86 |
+
<label class="block text-sm font-medium text-slate-300 mb-2">Voiceover Script & Subtitles</label>
|
| 87 |
+
<textarea id="scriptText" rows="6" required placeholder="Xin chào các bạn, hôm nay mình sẽ hướng dẫn..."
|
| 88 |
+
class="w-full bg-slate-900/50 border border-slate-700 text-slate-200 rounded-xl p-4 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-slate-600 resize-none font-medium"></textarea>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<!-- Submit Button -->
|
| 92 |
+
<button type="submit" id="submitBtn" class="w-full relative overflow-hidden group bg-white text-slate-900 font-semibold py-3.5 px-4 rounded-xl transition-all hover:scale-[1.02] hover:shadow-lg hover:shadow-white/10 flex items-center justify-center gap-2">
|
| 93 |
+
<span class="z-10 flex items-center gap-2">
|
| 94 |
+
<i data-feather="zap" class="w-5 h-5"></i> Generate Video
|
| 95 |
+
</span>
|
| 96 |
+
<div class="absolute inset-0 bg-gradient-to-r from-white to-slate-200 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
| 97 |
+
</button>
|
| 98 |
+
|
| 99 |
+
<p id="formError" class="text-red-400 text-sm font-medium hidden mt-2 text-center"></p>
|
| 100 |
+
|
| 101 |
+
</form>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<!-- Right Column: Result & Tracker -->
|
| 106 |
+
<div class="lg:col-span-7">
|
| 107 |
+
<div class="glass-panel rounded-2xl h-full shadow-xl flex flex-col overflow-hidden relative">
|
| 108 |
+
|
| 109 |
+
<!-- Status Bar -->
|
| 110 |
+
<div class="bg-slate-800/80 p-4 border-b border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
| 111 |
+
<div class="flex items-center gap-3">
|
| 112 |
+
<div id="statusDot" class="w-3 h-3 rounded-full bg-slate-600"></div>
|
| 113 |
+
<span id="statusText" class="text-sm font-medium text-slate-300">Idle - Ready to render</span>
|
| 114 |
+
</div>
|
| 115 |
+
<div id="jobIdContainer" class="hidden">
|
| 116 |
+
<span class="text-xs font-mono text-slate-500 bg-slate-900 px-2 py-1 rounded-md border border-slate-700" id="jobIdLabel"></span>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<!-- Preview Area -->
|
| 121 |
+
<div class="flex-1 min-h-[400px] flex items-center justify-center p-6 bg-slate-900/30 relative">
|
| 122 |
+
|
| 123 |
+
<!-- Empty State -->
|
| 124 |
+
<div id="emptyState" class="text-center text-slate-500">
|
| 125 |
+
<div class="w-20 h-20 mx-auto bg-slate-800 rounded-full flex items-center justify-center mb-4 border border-slate-700">
|
| 126 |
+
<i data-feather="film" class="w-8 h-8 text-slate-600"></i>
|
| 127 |
+
</div>
|
| 128 |
+
<p class="font-medium">Your generated video will appear here</p>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<!-- Loading State -->
|
| 132 |
+
<div id="loadingState" class="hidden flex-col items-center justify-center absolute inset-0 bg-slate-900/80 backdrop-blur-sm z-10 transition-all duration-300">
|
| 133 |
+
<div class="spinner mb-4 border-t-primary"></div>
|
| 134 |
+
<h3 class="font-semibold text-lg text-white mb-1">Rendering in progress...</h3>
|
| 135 |
+
<p class="text-sm text-slate-400">This might take a while depending on server capacity.</p>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<!-- Result Output -->
|
| 139 |
+
<div id="resultState" class="hidden w-full h-full flex flex-col items-center justify-center">
|
| 140 |
+
<video id="resultVideo" controls class="max-w-full max-h-[500px] rounded-lg shadow-2xl border border-slate-700 bg-black"></video>
|
| 141 |
+
<div class="mt-6 flex gap-4">
|
| 142 |
+
<a id="downloadBtn" href="#" target="_blank" class="px-5 py-2 rounded-lg bg-primary/10 text-primary border border-primary/30 hover:bg-primary/20 transition-colors font-medium flex items-center gap-2">
|
| 143 |
+
<i data-feather="download" class="w-4 h-4"></i> Download Result
|
| 144 |
+
</a>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
</main>
|
| 153 |
+
|
| 154 |
+
<script>
|
| 155 |
+
// Khởi tạo icons
|
| 156 |
+
feather.replace();
|
| 157 |
+
|
| 158 |
+
// Xử lý hiển thị tên file
|
| 159 |
+
document.getElementById('videoFile').addEventListener('change', function(e) {
|
| 160 |
+
const fileName = e.target.files[0] ? e.target.files[0].name : 'Select MP4 file...';
|
| 161 |
+
document.getElementById('videoFileName').textContent = fileName;
|
| 162 |
+
if(e.target.files[0]) document.getElementById('videoFileName').classList.remove('text-slate-400');
|
| 163 |
+
if(e.target.files[0]) document.getElementById('videoFileName').classList.add('text-white');
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
document.getElementById('audioFile').addEventListener('change', function(e) {
|
| 167 |
+
const fileName = e.target.files[0] ? e.target.files[0].name : 'Select audio file...';
|
| 168 |
+
document.getElementById('audioFileName').textContent = fileName;
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
// Xử lý Submit Form
|
| 172 |
+
const form = document.getElementById('uploadForm');
|
| 173 |
+
let pollInterval;
|
| 174 |
+
|
| 175 |
+
form.addEventListener('submit', async (e) => {
|
| 176 |
+
e.preventDefault();
|
| 177 |
+
|
| 178 |
+
const submitBtn = document.getElementById('submitBtn');
|
| 179 |
+
const errorTxt = document.getElementById('formError');
|
| 180 |
+
errorTxt.classList.add('hidden');
|
| 181 |
+
|
| 182 |
+
// Lấy dữ liệu
|
| 183 |
+
const videoFile = document.getElementById('videoFile').files[0];
|
| 184 |
+
const audioFile = document.getElementById('audioFile').files[0];
|
| 185 |
+
const scriptText = document.getElementById('scriptText').value;
|
| 186 |
+
|
| 187 |
+
// Đóng gói FormData
|
| 188 |
+
const formData = new FormData();
|
| 189 |
+
formData.append('script', scriptText);
|
| 190 |
+
formData.append('video', videoFile);
|
| 191 |
+
if (audioFile) formData.append('ref_audio', audioFile);
|
| 192 |
+
|
| 193 |
+
// Cập nhật UI loading Submit
|
| 194 |
+
submitBtn.disabled = true;
|
| 195 |
+
submitBtn.innerHTML = '<div class="spinner w-5 h-5 border-2 border-slate-900 border-t-transparent"></div> Uploading...';
|
| 196 |
+
|
| 197 |
+
// Ẩn states & Bật loading rendering
|
| 198 |
+
document.getElementById('emptyState').classList.add('hidden');
|
| 199 |
+
document.getElementById('resultState').classList.add('hidden');
|
| 200 |
+
document.getElementById('loadingState').classList.remove('hidden');
|
| 201 |
+
updateBadge('warning', 'Uploading files to server...');
|
| 202 |
+
|
| 203 |
+
try {
|
| 204 |
+
// Đẩy lên API
|
| 205 |
+
const response = await fetch('/api/v1/jobs/submit', {
|
| 206 |
+
method: 'POST',
|
| 207 |
+
body: formData
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
if (!response.ok) {
|
| 211 |
+
throw new Error(`Upload failed! Check your Supabase configuration or try again (Error ${response.status})`);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
const data = await response.json();
|
| 215 |
+
const jobId = data.job_id;
|
| 216 |
+
|
| 217 |
+
// Hiển thị Job ID
|
| 218 |
+
document.getElementById('jobIdContainer').classList.remove('hidden');
|
| 219 |
+
document.getElementById('jobIdLabel').textContent = `ID: ${jobId}`;
|
| 220 |
+
|
| 221 |
+
updateBadge('processing', 'In Queue - Polling status...');
|
| 222 |
+
|
| 223 |
+
// Bắt đầu pollling check status định kỳ mỗi 3 giây
|
| 224 |
+
pollInterval = setInterval(() => checkJobStatus(jobId), 3000);
|
| 225 |
+
|
| 226 |
+
} catch (err) {
|
| 227 |
+
console.error(err);
|
| 228 |
+
errorTxt.textContent = err.message;
|
| 229 |
+
errorTxt.classList.remove('hidden');
|
| 230 |
+
document.getElementById('loadingState').classList.add('hidden');
|
| 231 |
+
document.getElementById('emptyState').classList.remove('hidden');
|
| 232 |
+
updateBadge('error', 'Error occurred');
|
| 233 |
+
} finally {
|
| 234 |
+
// Khôi phục nút
|
| 235 |
+
submitBtn.disabled = false;
|
| 236 |
+
submitBtn.innerHTML = `
|
| 237 |
+
<span class="z-10 flex items-center gap-2">
|
| 238 |
+
<i data-feather="zap" class="w-5 h-5"></i> Generate Another
|
| 239 |
+
</span>
|
| 240 |
+
<div class="absolute inset-0 bg-gradient-to-r from-white to-slate-200 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
| 241 |
+
`;
|
| 242 |
+
feather.replace();
|
| 243 |
+
}
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
// Hàm kiểm tra trạng thái tiến trình
|
| 247 |
+
async function checkJobStatus(jobId) {
|
| 248 |
+
try {
|
| 249 |
+
const res = await fetch(`/api/v1/jobs/${jobId}`);
|
| 250 |
+
if (!res.ok) return;
|
| 251 |
+
|
| 252 |
+
const jobData = await res.json();
|
| 253 |
+
|
| 254 |
+
if (jobData.status === 'processing') {
|
| 255 |
+
updateBadge('processing', 'Rendering Engine Running...');
|
| 256 |
+
}
|
| 257 |
+
else if (jobData.status === 'completed') {
|
| 258 |
+
clearInterval(pollInterval);
|
| 259 |
+
updateBadge('success', 'Completed Successfully!');
|
| 260 |
+
showResult(jobData.result_url);
|
| 261 |
+
}
|
| 262 |
+
else if (jobData.status === 'failed' || jobData.status === 'error') {
|
| 263 |
+
clearInterval(pollInterval);
|
| 264 |
+
updateBadge('error', `Failed: ${jobData.error || 'Unknown rendering error'}`);
|
| 265 |
+
document.getElementById('loadingState').classList.add('hidden');
|
| 266 |
+
document.getElementById('emptyState').classList.remove('hidden');
|
| 267 |
+
alert("Render Task Failed! Check Celery Worker logs.");
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
} catch (e) {
|
| 271 |
+
console.error("Polling error", e);
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// Cập nhật thẻ trạng thái (Trắng, Vàng, Xanh, Đỏ)
|
| 276 |
+
function updateBadge(state, text) {
|
| 277 |
+
const dot = document.getElementById('statusDot');
|
| 278 |
+
const txt = document.getElementById('statusText');
|
| 279 |
+
txt.textContent = text;
|
| 280 |
+
|
| 281 |
+
// Reset class
|
| 282 |
+
dot.className = 'w-3 h-3 rounded-full';
|
| 283 |
+
|
| 284 |
+
if (state === 'idle') dot.classList.add('bg-slate-600');
|
| 285 |
+
if (state === 'warning') dot.classList.add('bg-yellow-500', 'animate-pulse');
|
| 286 |
+
if (state === 'processing') dot.classList.add('bg-blue-500', 'animate-pulse', 'shadow-[0_0_10px_rgba(59,130,246,0.6)]');
|
| 287 |
+
if (state === 'success') dot.classList.add('bg-green-500', 'shadow-[0_0_10px_rgba(34,197,94,0.6)]');
|
| 288 |
+
if (state === 'error') dot.classList.add('bg-red-500');
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
// Hiện kết quả
|
| 292 |
+
function showResult(videoUrl) {
|
| 293 |
+
document.getElementById('loadingState').classList.add('hidden');
|
| 294 |
+
document.getElementById('resultState').classList.remove('hidden');
|
| 295 |
+
|
| 296 |
+
const vid = document.getElementById('resultVideo');
|
| 297 |
+
vid.src = videoUrl;
|
| 298 |
+
vid.load();
|
| 299 |
+
|
| 300 |
+
const btn = document.getElementById('downloadBtn');
|
| 301 |
+
btn.href = videoUrl;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
</script>
|
| 305 |
+
</body>
|
| 306 |
+
</html>
|
templates/tts.html
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi" class="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Voice Studio | VieNeu AI</title>
|
| 7 |
+
<!-- Tailwind CSS CDN -->
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
<script>
|
| 10 |
+
tailwind.config = {
|
| 11 |
+
darkMode: 'class',
|
| 12 |
+
theme: {
|
| 13 |
+
extend: {
|
| 14 |
+
colors: {
|
| 15 |
+
primary: '#8b5cf6',
|
| 16 |
+
secondary: '#ec4899',
|
| 17 |
+
dark: '#0f172a',
|
| 18 |
+
card: 'rgba(30, 41, 59, 0.7)'
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
</script>
|
| 24 |
+
<link rel="stylesheet" href="/static/styles.css">
|
| 25 |
+
<script src="https://unpkg.com/feather-icons"></script>
|
| 26 |
+
</head>
|
| 27 |
+
<body class="text-slate-200 min-h-screen custom-bg selection:bg-primary selection:text-white font-inter">
|
| 28 |
+
|
| 29 |
+
<!-- Header -->
|
| 30 |
+
<header class="border-b border-slate-700/50 bg-slate-900/50 backdrop-blur-md sticky top-0 z-50">
|
| 31 |
+
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
| 32 |
+
<div class="flex items-center gap-3">
|
| 33 |
+
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-primary/20">
|
| 34 |
+
<i data-feather="mic" class="text-white w-5 h-5"></i>
|
| 35 |
+
</div>
|
| 36 |
+
<h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-slate-400">
|
| 37 |
+
Voice Studio <span class="font-normal text-sm text-primary ml-1">v2-Turbo</span>
|
| 38 |
+
</h1>
|
| 39 |
+
</div>
|
| 40 |
+
<nav class="flex gap-4 text-sm font-medium">
|
| 41 |
+
<a href="/" class="text-slate-400 hover:text-white transition-colors">Video AI</a>
|
| 42 |
+
<a href="/tts" class="text-white border-b-2 border-primary pb-1">Text to Speech</a>
|
| 43 |
+
</nav>
|
| 44 |
+
</div>
|
| 45 |
+
</header>
|
| 46 |
+
|
| 47 |
+
<main class="max-w-6xl mx-auto px-6 py-10 grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 48 |
+
|
| 49 |
+
<!-- Left Column: Controls (8 cols) -->
|
| 50 |
+
<div class="lg:col-span-8 space-y-6">
|
| 51 |
+
|
| 52 |
+
<!-- Text Input -->
|
| 53 |
+
<div class="glass-panel rounded-2xl p-6 shadow-xl">
|
| 54 |
+
<h2 class="text-lg font-semibold mb-4 text-white flex items-center gap-2">
|
| 55 |
+
<i data-feather="file-text" class="w-5 h-5 text-primary"></i> Nội dung kịch bản
|
| 56 |
+
</h2>
|
| 57 |
+
<textarea id="scriptText" rows="6" placeholder="Nhập văn bản Tiếng Việt hoặc Tiếng Anh vào đây..."
|
| 58 |
+
class="w-full bg-slate-900/50 border border-slate-700 text-slate-200 rounded-xl p-4 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-slate-600 resize-none font-medium text-lg leading-relaxed"></textarea>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<!-- Voice Settings -->
|
| 62 |
+
<div class="glass-panel rounded-2xl p-6 shadow-xl">
|
| 63 |
+
|
| 64 |
+
<!-- Custom Tabs -->
|
| 65 |
+
<div class="flex border-b border-slate-700 mb-6">
|
| 66 |
+
<button id="tabPreset" class="px-6 py-3 text-sm font-semibold text-primary border-b-2 border-primary flex gap-2 items-center transition-colors">
|
| 67 |
+
<i data-feather="users" class="w-4 h-4"></i> Giọng có sẵn
|
| 68 |
+
</button>
|
| 69 |
+
<button id="tabClone" class="px-6 py-3 text-sm font-semibold text-slate-400 hover:text-slate-200 flex gap-2 items-center transition-colors">
|
| 70 |
+
<i data-feather="copy" class="w-4 h-4"></i> Clone Giọng
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div id="presetContainer" class="space-y-6">
|
| 75 |
+
<!-- Dropdown Select -->
|
| 76 |
+
<div>
|
| 77 |
+
<label class="block text-sm font-medium text-slate-300 mb-2">Chọn nhân vật</label>
|
| 78 |
+
<div class="relative">
|
| 79 |
+
<select id="voicePreset" class="w-full appearance-none bg-slate-900/50 border border-slate-700 text-slate-200 rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary outline-none cursor-pointer font-medium">
|
| 80 |
+
<option value="bich_ngoc">Bích Ngọc (Nữ - Miền Bắc)</option>
|
| 81 |
+
<option value="pham_tuyen">Phạm Tuyên (Nam - Miền Bắc)</option>
|
| 82 |
+
<option value="thuc_doan">Thục Đoan (Nữ - Miền Nam)</option>
|
| 83 |
+
<option value="xuan_vinh">Xuân Vĩnh (Nam - Miền Nam)</option>
|
| 84 |
+
</select>
|
| 85 |
+
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-slate-400">
|
| 86 |
+
<i data-feather="chevron-down" class="w-4 h-4"></i>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div id="cloneContainer" class="hidden space-y-6">
|
| 93 |
+
<div>
|
| 94 |
+
<label class="block text-sm font-medium text-slate-300 mb-2">Upload file âm thanh giọng mẫu</label>
|
| 95 |
+
<div class="relative group">
|
| 96 |
+
<input type="file" id="cloneAudio" accept="audio/*" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
| 97 |
+
<div class="border-2 border-dashed border-slate-600 rounded-xl p-6 flex flex-col items-center justify-center gap-3 bg-slate-800/50 group-hover:bg-slate-800 transition-colors group-hover:border-primary/50">
|
| 98 |
+
<i data-feather="upload-cloud" class="w-8 h-8 text-slate-400 group-hover:text-primary transition-colors"></i>
|
| 99 |
+
<span id="cloneFileName" class="text-sm font-medium text-slate-400">Trích xuất giọng từ file (.mp3, .wav)</span>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<!-- Advanced Settings: Temperature Slider -->
|
| 106 |
+
<div class="mt-8 pt-6 border-t border-slate-700/50">
|
| 107 |
+
<div class="flex justify-between items-center mb-2">
|
| 108 |
+
<label class="text-sm font-medium text-slate-300 flex items-center gap-2">
|
| 109 |
+
<i data-feather="thermometer" class="w-4 h-4 text-secondary"></i> Nhiệt độ (Temperature)
|
| 110 |
+
</label>
|
| 111 |
+
<span id="tempValue" class="text-xs font-mono bg-slate-800 px-2 py-1 rounded text-primary">0.5</span>
|
| 112 |
+
</div>
|
| 113 |
+
<input type="range" id="temperature" min="0.1" max="1.5" step="0.1" value="0.5"
|
| 114 |
+
class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-primary">
|
| 115 |
+
<p class="text-xs text-slate-500 mt-2">Thấp = Khô khan chuẩn xác | Cao = Cảm xúc sáng tạo hơn.</p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<!-- Submit Button -->
|
| 120 |
+
<button id="submitBtn" class="w-full relative overflow-hidden group bg-gradient-to-r from-primary to-blue-600 text-white font-bold py-4 px-4 rounded-xl shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:shadow-[0_0_30px_rgba(139,92,246,0.5)] transition-all flex items-center justify-center gap-2">
|
| 121 |
+
<span class="z-10 flex items-center gap-2 text-lg">
|
| 122 |
+
<i data-feather="zap" class="w-5 h-5"></i> Bắt đầu tổng hợp Audio
|
| 123 |
+
</span>
|
| 124 |
+
</button>
|
| 125 |
+
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<!-- Right Column: Result (4 cols) -->
|
| 129 |
+
<div class="lg:col-span-4">
|
| 130 |
+
<div class="glass-panel flex flex-col rounded-2xl h-full shadow-xl sticky top-24">
|
| 131 |
+
|
| 132 |
+
<div class="p-6 border-b border-slate-700 flex items-center gap-2">
|
| 133 |
+
<i data-feather="headphones" class="w-5 h-5 text-secondary"></i>
|
| 134 |
+
<h3 class="font-semibold text-white">Audio Kết Quả</h3>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div class="flex-1 p-6 flex flex-col items-center justify-center min-h-[300px] relative">
|
| 138 |
+
|
| 139 |
+
<!-- Empty State -->
|
| 140 |
+
<div id="emptyResult" class="text-center">
|
| 141 |
+
<i data-feather="music" class="w-12 h-12 text-slate-700 mx-auto mb-4"></i>
|
| 142 |
+
<p class="text-slate-500 text-sm font-medium">Bấm "Bắt đầu" để tạo âm thanh</p>
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<!-- Loading State -->
|
| 146 |
+
<div id="loadingResult" class="hidden flex-col items-center">
|
| 147 |
+
<div class="spinner w-8 h-8 border-[3px] border-t-primary mb-4"></div>
|
| 148 |
+
<p class="text-primary font-medium animate-pulse">Đang tổng hợp...</p>
|
| 149 |
+
<p class="text-xs text-slate-500 mt-1">Sẽ mất vài giây để xử lý</p>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<!-- Audio Player Area -->
|
| 153 |
+
<div id="audioResult" class="hidden w-full flex flex-col items-center space-y-6">
|
| 154 |
+
<div class="w-20 h-20 bg-gradient-to-tr from-primary to-secondary rounded-full flex items-center justify-center shadow-[0_0_40px_rgba(236,72,153,0.3)] beep-anim">
|
| 155 |
+
<i data-feather="play" class="w-8 h-8 text-white ml-1"></i>
|
| 156 |
+
</div>
|
| 157 |
+
<audio id="player" controls class="w-full"></audio>
|
| 158 |
+
<a id="downloadBtn" href="#" download="vieneu_output.wav" class="text-sm font-medium text-slate-300 hover:text-white flex items-center gap-1 bg-slate-800 py-2 px-4 rounded-lg border border-slate-700 hover:border-slate-500 transition-colors">
|
| 159 |
+
<i data-feather="download" class="w-4 h-4"></i> Tải file .wav
|
| 160 |
+
</a>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
</main>
|
| 166 |
+
|
| 167 |
+
<style>
|
| 168 |
+
.beep-anim {
|
| 169 |
+
animation: pulse-glow 2s infinite;
|
| 170 |
+
}
|
| 171 |
+
@keyframes pulse-glow {
|
| 172 |
+
0% { box-shadow: 0 0 0 0 rgba(236,72,153, 0.4); }
|
| 173 |
+
70% { box-shadow: 0 0 0 20px rgba(236,72,153, 0); }
|
| 174 |
+
100% { box-shadow: 0 0 0 0 rgba(236,72,153, 0); }
|
| 175 |
+
}
|
| 176 |
+
</style>
|
| 177 |
+
|
| 178 |
+
<script>
|
| 179 |
+
feather.replace();
|
| 180 |
+
|
| 181 |
+
// Range Slider Value update
|
| 182 |
+
const tempSlider = document.getElementById('temperature');
|
| 183 |
+
const tempValue = document.getElementById('tempValue');
|
| 184 |
+
tempSlider.addEventListener('input', (e) => {
|
| 185 |
+
tempValue.textContent = parseFloat(e.target.value).toFixed(1);
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
// Tabs Logic
|
| 189 |
+
const tabPreset = document.getElementById('tabPreset');
|
| 190 |
+
const tabClone = document.getElementById('tabClone');
|
| 191 |
+
const presetContainer = document.getElementById('presetContainer');
|
| 192 |
+
const cloneContainer = document.getElementById('cloneContainer');
|
| 193 |
+
let isCloning = false;
|
| 194 |
+
|
| 195 |
+
tabPreset.addEventListener('click', () => {
|
| 196 |
+
isCloning = false;
|
| 197 |
+
tabPreset.className = "px-6 py-3 text-sm font-semibold text-primary border-b-2 border-primary flex gap-2 items-center transition-colors";
|
| 198 |
+
tabClone.className = "px-6 py-3 text-sm font-semibold text-slate-400 hover:text-slate-200 flex gap-2 items-center transition-colors border-b-2 border-transparent";
|
| 199 |
+
presetContainer.classList.remove('hidden');
|
| 200 |
+
cloneContainer.classList.add('hidden');
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
tabClone.addEventListener('click', () => {
|
| 204 |
+
isCloning = true;
|
| 205 |
+
tabClone.className = "px-6 py-3 text-sm font-semibold text-primary border-b-2 border-primary flex gap-2 items-center transition-colors";
|
| 206 |
+
tabPreset.className = "px-6 py-3 text-sm font-semibold text-slate-400 hover:text-slate-200 flex gap-2 items-center transition-colors border-b-2 border-transparent";
|
| 207 |
+
presetContainer.classList.add('hidden');
|
| 208 |
+
cloneContainer.classList.remove('hidden');
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
document.getElementById('cloneAudio').addEventListener('change', function(e) {
|
| 212 |
+
const fileName = e.target.files[0] ? e.target.files[0].name : 'Trích xuất giọng từ file (.mp3, .wav)';
|
| 213 |
+
document.getElementById('cloneFileName').textContent = fileName;
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
// Submit logic
|
| 217 |
+
const submitBtn = document.getElementById('submitBtn');
|
| 218 |
+
let pollInterval;
|
| 219 |
+
|
| 220 |
+
submitBtn.addEventListener('click', async () => {
|
| 221 |
+
const text = document.getElementById('scriptText').value;
|
| 222 |
+
if(!text.trim()) {
|
| 223 |
+
alert("Vui lòng nhập kịch bản cần đọc!");
|
| 224 |
+
return;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
const formData = new FormData();
|
| 228 |
+
formData.append('script', text);
|
| 229 |
+
formData.append('temperature', parseFloat(tempSlider.value));
|
| 230 |
+
|
| 231 |
+
if (isCloning) {
|
| 232 |
+
const cloneAudio = document.getElementById('cloneAudio').files[0];
|
| 233 |
+
if(cloneAudio) formData.append('ref_audio', cloneAudio);
|
| 234 |
+
} else {
|
| 235 |
+
formData.append('voice_preset', document.getElementById('voicePreset').value);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// Update UI
|
| 239 |
+
submitBtn.disabled = true;
|
| 240 |
+
submitBtn.classList.add('opacity-70');
|
| 241 |
+
document.getElementById('emptyResult').classList.add('hidden');
|
| 242 |
+
document.getElementById('audioResult').classList.add('hidden');
|
| 243 |
+
document.getElementById('loadingResult').classList.remove('hidden');
|
| 244 |
+
document.getElementById('loadingResult').classList.add('flex');
|
| 245 |
+
|
| 246 |
+
try {
|
| 247 |
+
const response = await fetch('/api/v1/tts/generate', {
|
| 248 |
+
method: 'POST',
|
| 249 |
+
body: formData
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
if (!response.ok) throw new Error("Upload Failed");
|
| 253 |
+
|
| 254 |
+
const data = await response.json();
|
| 255 |
+
const jobId = data.job_id;
|
| 256 |
+
|
| 257 |
+
pollInterval = setInterval(() => checkTtsJobStatus(jobId), 1500); // Check faster for TTS
|
| 258 |
+
} catch (err) {
|
| 259 |
+
alert("Error submitting request: " + err);
|
| 260 |
+
resetSubmitUI();
|
| 261 |
+
}
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
async function checkTtsJobStatus(jobId) {
|
| 265 |
+
try {
|
| 266 |
+
const res = await fetch(`/api/v1/jobs/${jobId}`);
|
| 267 |
+
if (!res.ok) return;
|
| 268 |
+
|
| 269 |
+
const jobData = await res.json();
|
| 270 |
+
|
| 271 |
+
if (jobData.status === 'completed') {
|
| 272 |
+
clearInterval(pollInterval);
|
| 273 |
+
showResult(jobData.result_url);
|
| 274 |
+
resetSubmitUI();
|
| 275 |
+
} else if (jobData.status === 'failed' || jobData.status === 'error') {
|
| 276 |
+
clearInterval(pollInterval);
|
| 277 |
+
alert("Lỗi quá trình Render TTS!");
|
| 278 |
+
resetSubmitUI();
|
| 279 |
+
}
|
| 280 |
+
} catch(e) {}
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
function showResult(audioUrl) {
|
| 284 |
+
document.getElementById('loadingResult').classList.add('hidden');
|
| 285 |
+
document.getElementById('loadingResult').classList.remove('flex');
|
| 286 |
+
document.getElementById('audioResult').classList.remove('hidden');
|
| 287 |
+
|
| 288 |
+
const player = document.getElementById('player');
|
| 289 |
+
player.src = audioUrl;
|
| 290 |
+
player.play(); // Auto play when done!
|
| 291 |
+
|
| 292 |
+
const btn = document.getElementById('downloadBtn');
|
| 293 |
+
btn.href = audioUrl;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function resetSubmitUI() {
|
| 297 |
+
submitBtn.disabled = false;
|
| 298 |
+
submitBtn.classList.remove('opacity-70');
|
| 299 |
+
if (document.getElementById('loadingResult').classList.contains('flex')) {
|
| 300 |
+
document.getElementById('loadingResult').classList.add('hidden');
|
| 301 |
+
document.getElementById('loadingResult').classList.remove('flex');
|
| 302 |
+
document.getElementById('emptyResult').classList.remove('hidden');
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
</script>
|
| 307 |
+
</body>
|
| 308 |
+
</html>
|
uv.lock
CHANGED
|
@@ -962,19 +962,14 @@ wheels = [
|
|
| 962 |
[[package]]
|
| 963 |
name = "llama-cpp-python"
|
| 964 |
version = "0.3.16"
|
| 965 |
-
source = { registry = "https://
|
| 966 |
dependencies = [
|
| 967 |
{ name = "diskcache" },
|
| 968 |
{ name = "jinja2" },
|
| 969 |
{ name = "numpy" },
|
| 970 |
{ name = "typing-extensions" },
|
| 971 |
]
|
| 972 |
-
|
| 973 |
-
{ url = "https://github.com/pnnbao97/VieNeu-TTS/releases/download/wheels-v0.3.16/llama_cpp_python-0.3.16-cp311-cp311-win_amd64.whl" },
|
| 974 |
-
{ url = "https://github.com/pnnbao97/VieNeu-TTS/releases/download/wheels-v0.3.16/llama_cpp_python-0.3.16-cp312-cp312-win_amd64.whl" },
|
| 975 |
-
{ url = "https://github.com/pnnbao97/VieNeu-TTS/releases/download/wheels-v0.3.16/llama_cpp_python-0.3.16-cp313-cp313-win_amd64.whl" },
|
| 976 |
-
{ url = "https://github.com/pnnbao97/VieNeu-TTS/releases/download/wheels-v0.3.16/llama_cpp_python-0.3.16-cp314-cp314-win_amd64.whl" },
|
| 977 |
-
]
|
| 978 |
|
| 979 |
[[package]]
|
| 980 |
name = "llvmlite"
|
|
@@ -2209,6 +2204,15 @@ wheels = [
|
|
| 2209 |
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
| 2210 |
]
|
| 2211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2212 |
[[package]]
|
| 2213 |
name = "python-multipart"
|
| 2214 |
version = "0.0.26"
|
|
@@ -2836,7 +2840,9 @@ dependencies = [
|
|
| 2836 |
{ name = "fastapi" },
|
| 2837 |
{ name = "faster-whisper" },
|
| 2838 |
{ name = "ffmpeg-python" },
|
|
|
|
| 2839 |
{ name = "pydantic" },
|
|
|
|
| 2840 |
{ name = "python-multipart" },
|
| 2841 |
{ name = "redis" },
|
| 2842 |
{ name = "supabase" },
|
|
@@ -2850,7 +2856,9 @@ requires-dist = [
|
|
| 2850 |
{ name = "fastapi", specifier = ">=0.136.0" },
|
| 2851 |
{ name = "faster-whisper", specifier = ">=1.2.1" },
|
| 2852 |
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
|
|
|
|
| 2853 |
{ name = "pydantic", specifier = ">=2.13.2" },
|
|
|
|
| 2854 |
{ name = "python-multipart", specifier = ">=0.0.26" },
|
| 2855 |
{ name = "redis", specifier = ">=7.4.0" },
|
| 2856 |
{ name = "supabase", specifier = ">=2.28.3" },
|
|
|
|
| 962 |
[[package]]
|
| 963 |
name = "llama-cpp-python"
|
| 964 |
version = "0.3.16"
|
| 965 |
+
source = { registry = "https://pypi.org/simple" }
|
| 966 |
dependencies = [
|
| 967 |
{ name = "diskcache" },
|
| 968 |
{ name = "jinja2" },
|
| 969 |
{ name = "numpy" },
|
| 970 |
{ name = "typing-extensions" },
|
| 971 |
]
|
| 972 |
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/b4/c8cd17629ced0b9644a71d399a91145aedef109c0333443bef015e45b704/llama_cpp_python-0.3.16.tar.gz", hash = "sha256:34ed0f9bd9431af045bb63d9324ae620ad0536653740e9bb163a2e1fcb973be6", size = 50688636, upload-time = "2025-08-15T04:58:29.212Z" }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 973 |
|
| 974 |
[[package]]
|
| 975 |
name = "llvmlite"
|
|
|
|
| 2204 |
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
| 2205 |
]
|
| 2206 |
|
| 2207 |
+
[[package]]
|
| 2208 |
+
name = "python-dotenv"
|
| 2209 |
+
version = "1.2.2"
|
| 2210 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2211 |
+
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
| 2212 |
+
wheels = [
|
| 2213 |
+
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
| 2214 |
+
]
|
| 2215 |
+
|
| 2216 |
[[package]]
|
| 2217 |
name = "python-multipart"
|
| 2218 |
version = "0.0.26"
|
|
|
|
| 2840 |
{ name = "fastapi" },
|
| 2841 |
{ name = "faster-whisper" },
|
| 2842 |
{ name = "ffmpeg-python" },
|
| 2843 |
+
{ name = "jinja2" },
|
| 2844 |
{ name = "pydantic" },
|
| 2845 |
+
{ name = "python-dotenv" },
|
| 2846 |
{ name = "python-multipart" },
|
| 2847 |
{ name = "redis" },
|
| 2848 |
{ name = "supabase" },
|
|
|
|
| 2856 |
{ name = "fastapi", specifier = ">=0.136.0" },
|
| 2857 |
{ name = "faster-whisper", specifier = ">=1.2.1" },
|
| 2858 |
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
|
| 2859 |
+
{ name = "jinja2", specifier = ">=3.1.6" },
|
| 2860 |
{ name = "pydantic", specifier = ">=2.13.2" },
|
| 2861 |
+
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
| 2862 |
{ name = "python-multipart", specifier = ">=0.0.26" },
|
| 2863 |
{ name = "redis", specifier = ">=7.4.0" },
|
| 2864 |
{ name = "supabase", specifier = ">=2.28.3" },
|
worker.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
from celery import Celery
|
| 3 |
import tempfile
|
| 4 |
-
from services.ai_pipeline import process_video_pipeline
|
|
|
|
| 5 |
|
| 6 |
# Initialize Celery pointing to Redis
|
| 7 |
celery_app = Celery(
|
|
@@ -47,3 +50,47 @@ def render_video_task(self, job_id: str, video_path: str, script: str, ref_audio
|
|
| 47 |
except Exception as e:
|
| 48 |
supabase.table("video_jobs").update({"status": "failed", "error": str(e)}).eq("id", job_id).execute()
|
| 49 |
raise e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
load_dotenv(override=True)
|
| 4 |
from celery import Celery
|
| 5 |
import tempfile
|
| 6 |
+
from services.ai_pipeline import process_video_pipeline, generate_tts_only
|
| 7 |
+
from supabase import create_client, Client
|
| 8 |
|
| 9 |
# Initialize Celery pointing to Redis
|
| 10 |
celery_app = Celery(
|
|
|
|
| 50 |
except Exception as e:
|
| 51 |
supabase.table("video_jobs").update({"status": "failed", "error": str(e)}).eq("id", job_id).execute()
|
| 52 |
raise e
|
| 53 |
+
|
| 54 |
+
@celery_app.task
|
| 55 |
+
def generate_tts_task(job_id: str, script: str, voice: str, temperature: float, ref_audio_path: str = None):
|
| 56 |
+
# Setup Supabase client per worker
|
| 57 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 58 |
+
SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
| 59 |
+
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 60 |
+
|
| 61 |
+
supabase.table("video_jobs").update({"status": "processing"}).eq("id", job_id).execute()
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 65 |
+
# 1. Download ref audio if it exists
|
| 66 |
+
local_ref_path = None
|
| 67 |
+
if ref_audio_path:
|
| 68 |
+
local_ref_path = os.path.join(tmpdir, "input_ref.wav")
|
| 69 |
+
with open(local_ref_path, 'wb') as f:
|
| 70 |
+
f.write(supabase.storage.from_("content").download(ref_audio_path))
|
| 71 |
+
|
| 72 |
+
# 2. Run Pure TTS Engine
|
| 73 |
+
result_audio_local = generate_tts_only(tmpdir, script, local_ref_path, temperature)
|
| 74 |
+
|
| 75 |
+
# 3. Upload Result Audio
|
| 76 |
+
final_audio_path = f"results/{job_id}.wav"
|
| 77 |
+
with open(result_audio_local, 'rb') as f:
|
| 78 |
+
supabase.storage.from_("content").upload(path=final_audio_path, file=f)
|
| 79 |
+
|
| 80 |
+
public_url = supabase.storage.from_("content").get_public_url(final_audio_path)
|
| 81 |
+
|
| 82 |
+
# 4. Mark job as complete
|
| 83 |
+
supabase.table("video_jobs").update({
|
| 84 |
+
"status": "completed",
|
| 85 |
+
"result_url": public_url
|
| 86 |
+
}).eq("id", job_id).execute()
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
import traceback
|
| 90 |
+
traceback.print_exc()
|
| 91 |
+
|
| 92 |
+
supabase.table("video_jobs").update({
|
| 93 |
+
"status": "error",
|
| 94 |
+
"error": str(e)
|
| 95 |
+
}).eq("id", job_id).execute()
|
| 96 |
+
raise e
|