Initial commit: Børnetime app scaffold
FastAPI + Celery + Svelte app for English→Danish video translation with voice cloning. Includes diarization, STT, translation, TTS, and audio mixing pipeline.
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
test
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
HF_TOKEN=hf_your_huggingface_token_here
|
||||||
|
# Models will be auto-downloaded on first run.
|
||||||
|
# For TranslateGemma GGUF, place the file at:
|
||||||
|
# ./models/translategemma-12b-q8_0.gguf
|
||||||
|
# Or set TRANSLATE_MODEL_PATH to the actual path.
|
||||||
|
#
|
||||||
|
# GPU selection:
|
||||||
|
# CUDA_VISIBLE_DEVICES=0 -> worker uses GPU 0
|
||||||
|
# ROCR_VISIBLE_DEVICES=1 -> SGLang TTS uses GPU 1
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
data/
|
||||||
|
models/
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
.env.local
|
||||||
|
*.mp4
|
||||||
|
*.mkv
|
||||||
|
*.avi
|
||||||
|
*.webm
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
fastapi uvicorn[standard] \
|
||||||
|
sqlalchemy[asyncio] aiosqlite \
|
||||||
|
pydantic pydantic-settings \
|
||||||
|
python-multipart aiofiles \
|
||||||
|
httptools
|
||||||
|
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY frontend/package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
COPY <<'EOF' /etc/nginx/conf.d/default.conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
FROM rocm/pytorch:rocm6.3.2_ubuntu24.04_py3.12
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
torch transformers accelerate \
|
||||||
|
boson_multimodal \
|
||||||
|
soundfile librosa \
|
||||||
|
pyannote.audio \
|
||||||
|
llama-cpp-python \
|
||||||
|
numpy aiofiles \
|
||||||
|
sqlalchemy aiosqlite \
|
||||||
|
pydantic pydantic-settings \
|
||||||
|
celery[redis] redis
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV HUGGINGFACE_HUB_CACHE=/data/hf_cache
|
||||||
|
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
CMD ["celery", "-A", "app.tasks.celery_app", "worker", "--loglevel=info", "--concurrency=1"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
FROM rocm/pytorch:rocm6.3.2_ubuntu24.04_py3.12
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
rocm-dev \
|
||||||
|
rocblas-dev \
|
||||||
|
hipblas-dev \
|
||||||
|
hipblaslt-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
torch transformers accelerate \
|
||||||
|
boson_multimodal \
|
||||||
|
soundfile librosa \
|
||||||
|
pyannote.audio \
|
||||||
|
numpy aiofiles \
|
||||||
|
sqlalchemy aiosqlite \
|
||||||
|
pydantic pydantic-settings \
|
||||||
|
celery[redis] redis
|
||||||
|
|
||||||
|
RUN CMAKE_ARGS="-DLLAMA_HIPBLAS=on -DCMAKE_PREFIX_PATH=/opt/rocm" \
|
||||||
|
pip install --no-cache-dir llama-cpp-python
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV HUGGINGFACE_HUB_CACHE=/data/hf_cache
|
||||||
|
ENV ROCM_PATH=/opt/rocm
|
||||||
|
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
RUN mkdir -p /data/app /data/videos /data/hf_cache
|
||||||
|
|
||||||
|
CMD ["celery", "-A", "app.tasks.celery_app", "worker", "--loglevel=info", "--concurrency=1"]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..models.database import get_session
|
||||||
|
from ..models.tables import Job, Video, JobStatus
|
||||||
|
from ..models.schemas import JobOut, JobListOut, ProcessResponse
|
||||||
|
from ..tasks.process import process_video
|
||||||
|
from ..tasks.generate import generate_audio
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=JobListOut)
|
||||||
|
async def list_jobs(session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(select(Job).order_by(Job.created_at.desc()))
|
||||||
|
jobs = result.scalars().all()
|
||||||
|
return JobListOut(jobs=[JobOut.model_validate(j) for j in jobs])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{job_id}", response_model=JobOut)
|
||||||
|
async def get_job(job_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(select(Job).where(Job.id == job_id))
|
||||||
|
job = result.scalar_one_or_none()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Job not found")
|
||||||
|
return JobOut.model_validate(job)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/cancel", response_model=ProcessResponse)
|
||||||
|
async def cancel_job(job_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(select(Job).where(Job.id == job_id))
|
||||||
|
job = result.scalar_one_or_none()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Job not found")
|
||||||
|
|
||||||
|
if job.status not in (JobStatus.pending.value, JobStatus.running.value):
|
||||||
|
raise HTTPException(400, f"Cannot cancel job in status '{job.status}'")
|
||||||
|
|
||||||
|
job.status = JobStatus.failed.value
|
||||||
|
job.error_message = "Cancelled by user"
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
video_result = await session.execute(select(Video).where(Video.id == job.video_id))
|
||||||
|
video = video_result.scalar_one_or_none()
|
||||||
|
if video:
|
||||||
|
if job.job_type == "process":
|
||||||
|
video.status = "uploaded"
|
||||||
|
elif job.job_type == "generate":
|
||||||
|
video.status = "review"
|
||||||
|
video.error_message = None
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return ProcessResponse(job_id=job.id, message="Job cancelled")
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import aiofiles
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..models.database import get_session
|
||||||
|
from ..models.tables import Video, Segment, Job, VideoStatus, JobStatus
|
||||||
|
from ..models.schemas import (
|
||||||
|
VideoOut,
|
||||||
|
VideoListOut,
|
||||||
|
SegmentOut,
|
||||||
|
SegmentUpdate,
|
||||||
|
TranscriptOut,
|
||||||
|
ProcessResponse,
|
||||||
|
)
|
||||||
|
from ..services.video_utils import get_video_duration
|
||||||
|
from ..config import settings
|
||||||
|
from ..tasks.process import process_video
|
||||||
|
from ..tasks.generate import generate_audio
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=VideoListOut)
|
||||||
|
async def list_videos(session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(select(Video).order_by(Video.created_at.desc()))
|
||||||
|
videos = result.scalars().all()
|
||||||
|
return VideoListOut(videos=[VideoOut.model_validate(v) for v in videos])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{video_id}", response_model=VideoOut)
|
||||||
|
async def get_video(video_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(select(Video).where(Video.id == video_id))
|
||||||
|
video = result.scalar_one_or_none()
|
||||||
|
if not video:
|
||||||
|
raise HTTPException(404, "Video not found")
|
||||||
|
return VideoOut.model_validate(video)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=VideoOut)
|
||||||
|
async def upload_video(
|
||||||
|
file: UploadFile = File(...), session: AsyncSession = Depends(get_session)
|
||||||
|
):
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(400, "No filename")
|
||||||
|
|
||||||
|
allowed = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v"}
|
||||||
|
ext = os.path.splitext(file.filename)[1].lower()
|
||||||
|
if ext not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
400, f"Unsupported format: {ext}. Allowed: {', '.join(allowed)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
dest = os.path.join(settings.video_dir, file.filename)
|
||||||
|
async with aiofiles.open(dest, "wb") as f:
|
||||||
|
while chunk := await file.read(64 * 1024):
|
||||||
|
await f.write(chunk)
|
||||||
|
|
||||||
|
duration = get_video_duration(dest)
|
||||||
|
|
||||||
|
video = Video(
|
||||||
|
filename=file.filename,
|
||||||
|
filepath=dest,
|
||||||
|
duration=duration,
|
||||||
|
status=VideoStatus.uploaded.value,
|
||||||
|
)
|
||||||
|
session.add(video)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(video)
|
||||||
|
|
||||||
|
return VideoOut.model_validate(video)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{video_id}")
|
||||||
|
async def delete_video(video_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(select(Video).where(Video.id == video_id))
|
||||||
|
video = result.scalar_one_or_none()
|
||||||
|
if not video:
|
||||||
|
raise HTTPException(404, "Video not found")
|
||||||
|
|
||||||
|
if os.path.exists(video.filepath):
|
||||||
|
os.remove(video.filepath)
|
||||||
|
if video.output_path:
|
||||||
|
output_full = os.path.join(settings.video_dir, video.output_path)
|
||||||
|
if os.path.exists(output_full):
|
||||||
|
os.remove(output_full)
|
||||||
|
|
||||||
|
await session.delete(video)
|
||||||
|
await session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{video_id}/process", response_model=ProcessResponse)
|
||||||
|
async def start_processing(video_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(select(Video).where(Video.id == video_id))
|
||||||
|
video = result.scalar_one_or_none()
|
||||||
|
if not video:
|
||||||
|
raise HTTPException(404, "Video not found")
|
||||||
|
|
||||||
|
if video.status not in (VideoStatus.uploaded.value, VideoStatus.error.value):
|
||||||
|
raise HTTPException(400, f"Cannot process video in status '{video.status}'")
|
||||||
|
|
||||||
|
existing_job = await session.execute(
|
||||||
|
select(Job).where(
|
||||||
|
Job.video_id == video_id,
|
||||||
|
Job.job_type == "process",
|
||||||
|
Job.status.in_([JobStatus.pending.value, JobStatus.running.value]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_job.scalar_one_or_none():
|
||||||
|
raise HTTPException(400, "A process job is already running for this video")
|
||||||
|
|
||||||
|
job = Job(video_id=video_id, job_type="process", status=JobStatus.pending.value)
|
||||||
|
session.add(job)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(job)
|
||||||
|
|
||||||
|
video.status = VideoStatus.queued.value
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
process_video.delay(video_id=video_id, job_id=job.id)
|
||||||
|
|
||||||
|
return ProcessResponse(job_id=job.id, message="Processing started")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{video_id}/transcript", response_model=TranscriptOut)
|
||||||
|
async def get_transcript(video_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(
|
||||||
|
select(Segment).where(Segment.video_id == video_id).order_by(Segment.start_time)
|
||||||
|
)
|
||||||
|
segments = result.scalars().all()
|
||||||
|
return TranscriptOut(
|
||||||
|
video_id=video_id,
|
||||||
|
segments=[SegmentOut.model_validate(s) for s in segments],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{video_id}/transcript/{segment_id}", response_model=SegmentOut)
|
||||||
|
async def update_segment(
|
||||||
|
video_id: int,
|
||||||
|
segment_id: int,
|
||||||
|
update: SegmentUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
result = await session.execute(
|
||||||
|
select(Segment).where(Segment.id == segment_id, Segment.video_id == video_id)
|
||||||
|
)
|
||||||
|
seg = result.scalar_one_or_none()
|
||||||
|
if not seg:
|
||||||
|
raise HTTPException(404, "Segment not found")
|
||||||
|
|
||||||
|
if update.english_text is not None:
|
||||||
|
seg.english_text = update.english_text
|
||||||
|
if update.danish_text is not None:
|
||||||
|
seg.danish_text = update.danish_text
|
||||||
|
if update.speaker_label is not None:
|
||||||
|
seg.speaker_label = update.speaker_label
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(seg)
|
||||||
|
return SegmentOut.model_validate(seg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{video_id}/file")
|
||||||
|
async def stream_video(video_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(select(Video).where(Video.id == video_id))
|
||||||
|
video = result.scalar_one_or_none()
|
||||||
|
if not video:
|
||||||
|
raise HTTPException(404, "Video not found")
|
||||||
|
if not os.path.exists(video.filepath):
|
||||||
|
raise HTTPException(404, "Video file not found on disk")
|
||||||
|
return FileResponse(video.filepath, media_type="video/mp4", filename=video.filename)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/file/{filename}")
|
||||||
|
async def stream_output(filename: str):
|
||||||
|
full_path = os.path.join(settings.video_dir, filename)
|
||||||
|
if not os.path.exists(full_path):
|
||||||
|
raise HTTPException(404, "File not found")
|
||||||
|
return FileResponse(full_path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{video_id}/generate", response_model=ProcessResponse)
|
||||||
|
async def start_generation(video_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
result = await session.execute(select(Video).where(Video.id == video_id))
|
||||||
|
video = result.scalar_one_or_none()
|
||||||
|
if not video:
|
||||||
|
raise HTTPException(404, "Video not found")
|
||||||
|
|
||||||
|
if video.status != VideoStatus.review.value:
|
||||||
|
raise HTTPException(400, f"Cannot generate audio in status '{video.status}'")
|
||||||
|
|
||||||
|
existing_job = await session.execute(
|
||||||
|
select(Job).where(
|
||||||
|
Job.video_id == video_id,
|
||||||
|
Job.job_type == "generate",
|
||||||
|
Job.status.in_([JobStatus.pending.value, JobStatus.running.value]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_job.scalar_one_or_none():
|
||||||
|
raise HTTPException(400, "A generate job is already running for this video")
|
||||||
|
|
||||||
|
job = Job(video_id=video_id, job_type="generate", status=JobStatus.pending.value)
|
||||||
|
session.add(job)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(job)
|
||||||
|
|
||||||
|
video.status = VideoStatus.generating.value
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
generate_audio.delay(video_id=video_id, job_id=job.id)
|
||||||
|
|
||||||
|
return ProcessResponse(job_id=job.id, message="Audio generation started")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
video_dir: str = "/data/videos"
|
||||||
|
app_data_dir: str = "/data/app"
|
||||||
|
database_url: str = "sqlite+aiosqlite:////data/app/bornetime.db"
|
||||||
|
|
||||||
|
redis_url: str = "redis://redis:6379/0"
|
||||||
|
|
||||||
|
stt_model_id: str = "bosonai/higgs-audio-v3-stt"
|
||||||
|
stt_device: str = "cuda:0"
|
||||||
|
stt_dtype: str = "bfloat16"
|
||||||
|
|
||||||
|
translate_model_path: str = "/models/translategemma-12b-q8_0.gguf"
|
||||||
|
translate_n_gpu_layers: int = -1
|
||||||
|
translate_device: str = "cuda:0"
|
||||||
|
|
||||||
|
tts_model_id: str = "bosonai/higgs-tts-3-4b"
|
||||||
|
tts_device: str = "cuda:1"
|
||||||
|
|
||||||
|
diarization_model: str = "pyannote/speaker-diarization-3.1"
|
||||||
|
diarization_device: str = "cuda:0"
|
||||||
|
|
||||||
|
hf_token: Optional[str] = None
|
||||||
|
temp_dir: str = "/data/app/tmp"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .models.database import init_db
|
||||||
|
from .api.videos import router as videos_router
|
||||||
|
from .api.jobs import router as jobs_router
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
os.makedirs(settings.video_dir, exist_ok=True)
|
||||||
|
os.makedirs(settings.app_data_dir, exist_ok=True)
|
||||||
|
os.makedirs(settings.temp_dir, exist_ok=True)
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="B\u00f8rnetime", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(videos_router, prefix="/api/videos", tags=["videos"])
|
||||||
|
app.include_router(jobs_router, prefix="/api/jobs", tags=["jobs"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import logging
|
||||||
|
import torch
|
||||||
|
from typing import Optional
|
||||||
|
from pyannote.audio import Pipeline
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_diarization_pipeline: Optional[Pipeline] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_diarization_pipeline() -> Pipeline:
|
||||||
|
global _diarization_pipeline
|
||||||
|
if _diarization_pipeline is None:
|
||||||
|
import os
|
||||||
|
|
||||||
|
hf_token = settings.hf_token or os.environ.get("HUGGINGFACE_TOKEN")
|
||||||
|
logger.info("Loading diarization model on %s", settings.diarization_device)
|
||||||
|
_diarization_pipeline = Pipeline.from_pretrained(
|
||||||
|
settings.diarization_model,
|
||||||
|
use_auth_token=hf_token,
|
||||||
|
)
|
||||||
|
_diarization_pipeline.to(torch.device(settings.diarization_device))
|
||||||
|
return _diarization_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def diarize(audio_path: str) -> list[dict]:
|
||||||
|
pipeline = get_diarization_pipeline()
|
||||||
|
diarization = pipeline(audio_path)
|
||||||
|
|
||||||
|
segments = []
|
||||||
|
for turn, _, speaker in diarization.itertracks(yield_label=True):
|
||||||
|
segments.append(
|
||||||
|
{
|
||||||
|
"speaker_label": speaker,
|
||||||
|
"start_time": turn.start,
|
||||||
|
"end_time": turn.end,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
segments.sort(key=lambda s: s["start_time"])
|
||||||
|
segments = _merge_adjacent_same_speaker(segments)
|
||||||
|
segments = _relabel_speakers(segments)
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_adjacent_same_speaker(
|
||||||
|
segments: list[dict], max_gap: float = 0.5
|
||||||
|
) -> list[dict]:
|
||||||
|
if not segments:
|
||||||
|
return []
|
||||||
|
|
||||||
|
merged = [segments[0]]
|
||||||
|
for seg in segments[1:]:
|
||||||
|
prev = merged[-1]
|
||||||
|
if (
|
||||||
|
prev["speaker_label"] == seg["speaker_label"]
|
||||||
|
and (seg["start_time"] - prev["end_time"]) <= max_gap
|
||||||
|
):
|
||||||
|
prev["end_time"] = seg["end_time"]
|
||||||
|
else:
|
||||||
|
merged.append(seg)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _relabel_speakers(segments: list[dict]) -> list[dict]:
|
||||||
|
label_map = {}
|
||||||
|
counter = 0
|
||||||
|
for seg in segments:
|
||||||
|
spk = seg["speaker_label"]
|
||||||
|
if spk not in label_map:
|
||||||
|
label = chr(65 + counter)
|
||||||
|
label_map[spk] = f"Speaker {label}"
|
||||||
|
counter += 1
|
||||||
|
seg["speaker_label"] = label_map[spk]
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def unload_model():
|
||||||
|
global _diarization_pipeline
|
||||||
|
_diarization_pipeline = None
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from typing import Optional
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def mix_audio(
|
||||||
|
video_path: str,
|
||||||
|
segments: list[dict],
|
||||||
|
output_path: str,
|
||||||
|
):
|
||||||
|
temp_dir = settings.temp_dir
|
||||||
|
|
||||||
|
original_audio_path = os.path.join(temp_dir, "original_audio.wav")
|
||||||
|
_extract_audio(video_path, original_audio_path)
|
||||||
|
|
||||||
|
original_audio, orig_sr = sf.read(original_audio_path)
|
||||||
|
if len(original_audio.shape) > 1:
|
||||||
|
original_audio = original_audio.mean(axis=1)
|
||||||
|
|
||||||
|
target_sr = 24000
|
||||||
|
if orig_sr != target_sr:
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
original_audio = librosa.resample(
|
||||||
|
original_audio, orig_sr=orig_sr, target_sr=target_sr
|
||||||
|
)
|
||||||
|
|
||||||
|
total_samples = len(original_audio)
|
||||||
|
danish_track = np.zeros(total_samples, dtype=np.float32)
|
||||||
|
|
||||||
|
for seg in segments:
|
||||||
|
danish_audio = seg.get("danish_audio")
|
||||||
|
if danish_audio is None or len(danish_audio) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_sample = int(seg["start_time"] * target_sr)
|
||||||
|
end_sample = min(start_sample + len(danish_audio), total_samples)
|
||||||
|
actual_len = end_sample - start_sample
|
||||||
|
danish_track[start_sample:end_sample] += danish_audio[:actual_len]
|
||||||
|
|
||||||
|
volume_envelope = np.ones(total_samples, dtype=np.float32)
|
||||||
|
for seg in segments:
|
||||||
|
start_sample = int(seg["start_time"] * target_sr)
|
||||||
|
end_sample = int(seg["end_time"] * target_sr)
|
||||||
|
volume_envelope[start_sample:end_sample] = 0.23
|
||||||
|
|
||||||
|
orig_adjusted = original_audio * volume_envelope
|
||||||
|
|
||||||
|
mixed = orig_adjusted + danish_track
|
||||||
|
peak = np.max(np.abs(mixed))
|
||||||
|
if peak > 0.99:
|
||||||
|
mixed = mixed / peak * 0.95
|
||||||
|
|
||||||
|
mixed_path = os.path.join(temp_dir, "mixed_audio.wav")
|
||||||
|
sf.write(mixed_path, mixed, target_sr)
|
||||||
|
|
||||||
|
_replace_audio(video_path, mixed_path, output_path)
|
||||||
|
|
||||||
|
for f in [original_audio_path, mixed_path]:
|
||||||
|
if os.path.exists(f):
|
||||||
|
os.remove(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_audio(video_path: str, output_path: str):
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
video_path,
|
||||||
|
"-vn",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-ar",
|
||||||
|
"24000",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
output_path,
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Audio extraction failed: {result.stderr}")
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_audio(video_path: str, audio_path: str, output_path: str):
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
video_path,
|
||||||
|
"-i",
|
||||||
|
audio_path,
|
||||||
|
"-c:v",
|
||||||
|
"copy",
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-b:a",
|
||||||
|
"192k",
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-map",
|
||||||
|
"1:a:0",
|
||||||
|
"-shortest",
|
||||||
|
output_path,
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Audio replacement failed: {result.stderr}")
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
import librosa
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
from dataclasses import asdict
|
||||||
|
from typing import Optional
|
||||||
|
from transformers import (
|
||||||
|
AutoConfig,
|
||||||
|
AutoModel,
|
||||||
|
AutoTokenizer,
|
||||||
|
WhisperProcessor,
|
||||||
|
)
|
||||||
|
from boson_multimodal.data_collator.higgs_audio_collator import HiggsAudioSampleCollator
|
||||||
|
from boson_multimodal.data_types import ChatMLSample, AudioContent, Message
|
||||||
|
from boson_multimodal.dataset.chatml_dataset import (
|
||||||
|
ChatMLDatasetSample,
|
||||||
|
prepare_chatml_sample_qwen,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_model: Optional[AutoModel] = None
|
||||||
|
_tokenizer: Optional[AutoTokenizer] = None
|
||||||
|
_collator: Optional[HiggsAudioSampleCollator] = None
|
||||||
|
_config: Optional[AutoConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_model():
|
||||||
|
global _model, _tokenizer, _collator, _config
|
||||||
|
if _model is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Loading Higgs STT on %s", settings.stt_device)
|
||||||
|
dtype = torch.bfloat16 if settings.stt_dtype == "bfloat16" else torch.float16
|
||||||
|
|
||||||
|
_config = AutoConfig.from_pretrained(settings.stt_model_id, trust_remote_code=True)
|
||||||
|
_model = AutoModel.from_pretrained(
|
||||||
|
settings.stt_model_id,
|
||||||
|
torch_dtype=dtype,
|
||||||
|
trust_remote_code=True,
|
||||||
|
attn_implementation="eager",
|
||||||
|
device_map=settings.stt_device,
|
||||||
|
)
|
||||||
|
_model.eval()
|
||||||
|
_tokenizer = AutoTokenizer.from_pretrained(settings.stt_model_id)
|
||||||
|
_model.audio_out_bos_token_id = _tokenizer.convert_tokens_to_ids(
|
||||||
|
"<|audio_out_bos|>"
|
||||||
|
)
|
||||||
|
_model.audio_eos_token_id = _tokenizer.convert_tokens_to_ids("<|audio_eos|>")
|
||||||
|
|
||||||
|
whisper_proc = WhisperProcessor.from_pretrained("openai/whisper-large-v3")
|
||||||
|
_collator = HiggsAudioSampleCollator(
|
||||||
|
whisper_processor=whisper_proc,
|
||||||
|
audio_in_token_id=_config.audio_in_token_idx,
|
||||||
|
audio_out_token_id=_config.audio_out_token_idx,
|
||||||
|
audio_stream_bos_id=_config.audio_stream_bos_id,
|
||||||
|
audio_stream_eos_id=_config.audio_stream_eos_id,
|
||||||
|
encode_whisper_embed=_config.encode_whisper_embed,
|
||||||
|
pad_token_id=_config.pad_token_id,
|
||||||
|
return_audio_in_tokens=_config.encode_audio_in_tokens,
|
||||||
|
use_delay_pattern=_config.use_delay_pattern,
|
||||||
|
round_to=1,
|
||||||
|
audio_num_codebooks=_config.audio_num_codebooks,
|
||||||
|
chunk_size_seconds=getattr(_config, "chunk_size_seconds", 30),
|
||||||
|
encoder_padding_method=getattr(_config, "encoder_padding_method", "max_length"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_segment(audio_path: str, start_time: float, end_time: float) -> str:
|
||||||
|
_load_model()
|
||||||
|
|
||||||
|
audio_np, sr = sf.read(audio_path)
|
||||||
|
if sr != 16000:
|
||||||
|
audio_np = librosa.resample(audio_np, orig_sr=sr, target_sr=16000)
|
||||||
|
|
||||||
|
start_sample = int(start_time * 16000)
|
||||||
|
end_sample = int(end_time * 16000)
|
||||||
|
segment_audio = audio_np[start_sample:end_sample]
|
||||||
|
|
||||||
|
if len(segment_audio) == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
prompt = "Transcribe the speech. Output only the spoken words in lowercase with no punctuation."
|
||||||
|
messages = [
|
||||||
|
Message(role="user", content=[prompt, AudioContent(audio_url="placeholder")])
|
||||||
|
]
|
||||||
|
chatml = ChatMLSample(messages=messages)
|
||||||
|
prep_fn = partial(prepare_chatml_sample_qwen, enable_thinking=True)
|
||||||
|
input_tokens, _, _, _ = prep_fn(chatml, _tokenizer, add_generation_prompt=True)
|
||||||
|
|
||||||
|
sample = ChatMLDatasetSample(
|
||||||
|
input_ids=torch.LongTensor(input_tokens),
|
||||||
|
label_ids=None,
|
||||||
|
audio_ids_concat=None,
|
||||||
|
audio_ids_start=None,
|
||||||
|
audio_waveforms_concat=torch.tensor(segment_audio, dtype=torch.float32),
|
||||||
|
audio_waveforms_start=torch.tensor([0]),
|
||||||
|
audio_sample_rate=torch.tensor([16000]),
|
||||||
|
audio_speaker_indices=torch.tensor([0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
batch = asdict(_collator([sample]))
|
||||||
|
device = next(_model.parameters()).device
|
||||||
|
batch = {
|
||||||
|
k: v.to(device).contiguous() if isinstance(v, torch.Tensor) else v
|
||||||
|
for k, v in batch.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
outputs = _model.generate(
|
||||||
|
**batch,
|
||||||
|
max_new_tokens=1024,
|
||||||
|
use_cache=True,
|
||||||
|
do_sample=False,
|
||||||
|
stop_strings=["<|im_end|>", "<|endoftext|>"],
|
||||||
|
tokenizer=_tokenizer,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_ids = outputs[0] if isinstance(outputs, tuple) else outputs
|
||||||
|
full_text = _tokenizer.decode(output_ids[0], skip_special_tokens=False)
|
||||||
|
|
||||||
|
parts = full_text.split("assistant\n")
|
||||||
|
hyp = parts[-1] if len(parts) > 1 else full_text
|
||||||
|
hyp = re.sub(r"<think>.*?</think>", "", hyp, flags=re.DOTALL)
|
||||||
|
hyp = re.sub(r"<\|.*?\|>", "", hyp).strip()
|
||||||
|
|
||||||
|
return hyp
|
||||||
|
|
||||||
|
|
||||||
|
def unload_model():
|
||||||
|
global _model, _tokenizer, _collator, _config
|
||||||
|
_model = None
|
||||||
|
_tokenizer = None
|
||||||
|
_collator = None
|
||||||
|
_config = None
|
||||||
|
torch.cuda.empty_cache()
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import logging
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_llm = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_model():
|
||||||
|
global _llm
|
||||||
|
if _llm is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
from llama_cpp import Llama
|
||||||
|
|
||||||
|
logger.info("Loading TranslateGemma 12B from %s", settings.translate_model_path)
|
||||||
|
_llm = Llama(
|
||||||
|
model_path=settings.translate_model_path,
|
||||||
|
n_gpu_layers=settings.translate_n_gpu_layers,
|
||||||
|
n_ctx=2048,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def translate(text: str) -> str:
|
||||||
|
_load_model()
|
||||||
|
|
||||||
|
if not text.strip():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"Translate the following English text to Danish.",
|
||||||
|
"Preserve the original meaning, tone, and style.",
|
||||||
|
"Output only the Danish translation, nothing else.",
|
||||||
|
"",
|
||||||
|
f"English: {text}",
|
||||||
|
"",
|
||||||
|
"Danish:",
|
||||||
|
]
|
||||||
|
prompt = "\n".join(lines)
|
||||||
|
|
||||||
|
stop_tokens = ["\n\n", "English:", "User:"]
|
||||||
|
output = _llm(
|
||||||
|
prompt,
|
||||||
|
max_tokens=1024,
|
||||||
|
temperature=0.1,
|
||||||
|
stop=stop_tokens,
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = output["choices"][0]["text"].strip()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def unload_model():
|
||||||
|
global _llm
|
||||||
|
_llm = None
|
||||||
|
import torch
|
||||||
|
|
||||||
|
torch.cuda.empty_cache()
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_model = None
|
||||||
|
_tokenizer = None
|
||||||
|
_use_http = True
|
||||||
|
|
||||||
|
|
||||||
|
def _load_http_mode():
|
||||||
|
global _use_http
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
settings.tts_api_url.replace("/v1/audio/speech", "/health"), timeout=5
|
||||||
|
)
|
||||||
|
if resp.ok:
|
||||||
|
_use_http = True
|
||||||
|
logger.info("TTS sidecar available at %s", settings.tts_api_url)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_use_http = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_model():
|
||||||
|
global _model, _tokenizer
|
||||||
|
if _model is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Loading Higgs TTS 3 on %s", settings.tts_device)
|
||||||
|
from transformers import AutoModel, AutoTokenizer
|
||||||
|
|
||||||
|
_model = AutoModel.from_pretrained(
|
||||||
|
settings.tts_model_id,
|
||||||
|
torch_dtype=torch.bfloat16,
|
||||||
|
trust_remote_code=True,
|
||||||
|
device_map=settings.tts_device,
|
||||||
|
)
|
||||||
|
_model.eval()
|
||||||
|
|
||||||
|
_tokenizer = AutoTokenizer.from_pretrained(settings.tts_model_id)
|
||||||
|
logger.info("Higgs TTS 3 loaded successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_segment(
|
||||||
|
text: str,
|
||||||
|
reference_audio_path: Optional[str] = None,
|
||||||
|
reference_text: Optional[str] = None,
|
||||||
|
speaker_label: Optional[str] = None,
|
||||||
|
) -> bytes:
|
||||||
|
if not text.strip():
|
||||||
|
return b""
|
||||||
|
|
||||||
|
input_text = text
|
||||||
|
if speaker_label:
|
||||||
|
input_text = f"<|emotion:contentment|><|prosody:expressive_low|>{text}"
|
||||||
|
|
||||||
|
if _use_http:
|
||||||
|
try:
|
||||||
|
return _synthesize_http(input_text, reference_audio_path, reference_text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("HTTP TTS failed, falling back to direct: %s", e)
|
||||||
|
|
||||||
|
return _synthesize_direct(input_text, reference_audio_path, reference_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _synthesize_http(
|
||||||
|
text: str,
|
||||||
|
reference_audio_path: Optional[str] = None,
|
||||||
|
reference_text: Optional[str] = None,
|
||||||
|
) -> bytes:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"input": text,
|
||||||
|
"model": settings.tts_model_id,
|
||||||
|
"temperature": 0.8,
|
||||||
|
"top_k": 50,
|
||||||
|
"max_new_tokens": 2048,
|
||||||
|
}
|
||||||
|
|
||||||
|
if reference_audio_path and reference_text:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
with open(reference_audio_path, "rb") as f:
|
||||||
|
audio_b64 = base64.b64encode(f.read()).decode()
|
||||||
|
payload["references"] = [
|
||||||
|
{
|
||||||
|
"audio": audio_b64,
|
||||||
|
"text": reference_text,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = requests.post(settings.tts_api_url, json=payload, timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def _synthesize_direct(
|
||||||
|
text: str,
|
||||||
|
reference_audio_path: Optional[str] = None,
|
||||||
|
reference_text: Optional[str] = None,
|
||||||
|
) -> bytes:
|
||||||
|
_load_model()
|
||||||
|
|
||||||
|
from boson_multimodal.data_types import ChatMLSample, Message
|
||||||
|
from boson_multimodal.dataset.chatml_dataset import (
|
||||||
|
ChatMLDatasetSample,
|
||||||
|
prepare_chatml_sample_qwen,
|
||||||
|
)
|
||||||
|
from boson_multimodal.data_collator.higgs_audio_collator import (
|
||||||
|
HiggsAudioSampleCollator,
|
||||||
|
)
|
||||||
|
from functools import partial
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
prompt = f"Generate speech for the following text. Output only the spoken words."
|
||||||
|
messages = [Message(role="user", content=[prompt])]
|
||||||
|
chatml = ChatMLSample(messages=messages)
|
||||||
|
prep_fn = partial(prepare_chatml_sample_qwen, enable_thinking=False)
|
||||||
|
input_tokens, _, _, _ = prep_fn(chatml, _tokenizer, add_generation_prompt=True)
|
||||||
|
|
||||||
|
ref_audio = None
|
||||||
|
if reference_audio_path:
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
ref_audio, ref_sr = sf.read(reference_audio_path)
|
||||||
|
if ref_sr != 16000:
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
ref_audio = librosa.resample(ref_audio, orig_sr=ref_sr, target_sr=16000)
|
||||||
|
ref_audio = torch.tensor(ref_audio, dtype=torch.float32)
|
||||||
|
|
||||||
|
sample = ChatMLDatasetSample(
|
||||||
|
input_ids=torch.LongTensor(input_tokens),
|
||||||
|
label_ids=None,
|
||||||
|
audio_ids_concat=None,
|
||||||
|
audio_ids_start=None,
|
||||||
|
audio_waveforms_concat=ref_audio if ref_audio is not None else torch.zeros(1),
|
||||||
|
audio_waveforms_start=torch.tensor([0]),
|
||||||
|
audio_sample_rate=torch.tensor([16000]),
|
||||||
|
audio_speaker_indices=torch.tensor([0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
collator = HiggsAudioSampleCollator(
|
||||||
|
whisper_processor=None,
|
||||||
|
audio_in_token_id=getattr(_model.config, "audio_in_token_idx", 0),
|
||||||
|
audio_out_token_id=getattr(_model.config, "audio_out_token_idx", 1),
|
||||||
|
audio_stream_bos_id=getattr(_model.config, "audio_stream_bos_id", 2),
|
||||||
|
audio_stream_eos_id=getattr(_model.config, "audio_stream_eos_id", 3),
|
||||||
|
encode_whisper_embed=getattr(_model.config, "encode_whisper_embed", False),
|
||||||
|
pad_token_id=_tokenizer.pad_token_id or 0,
|
||||||
|
return_audio_in_tokens=getattr(_model.config, "encode_audio_in_tokens", False),
|
||||||
|
use_delay_pattern=getattr(_model.config, "use_delay_pattern", True),
|
||||||
|
round_to=1,
|
||||||
|
audio_num_codebooks=getattr(_model.config, "audio_num_codebooks", 8),
|
||||||
|
chunk_size_seconds=getattr(_model.config, "chunk_size_seconds", 30),
|
||||||
|
encoder_padding_method=getattr(
|
||||||
|
_model.config, "encoder_padding_method", "max_length"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
batch = asdict(collator([sample]))
|
||||||
|
device = next(_model.parameters()).device
|
||||||
|
batch = {
|
||||||
|
k: v.to(device).contiguous() if isinstance(v, torch.Tensor) else v
|
||||||
|
for k, v in batch.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
outputs = _model.generate(
|
||||||
|
**batch,
|
||||||
|
max_new_tokens=2048,
|
||||||
|
temperature=0.8,
|
||||||
|
top_k=50,
|
||||||
|
do_sample=True,
|
||||||
|
use_cache=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_ids = outputs[0] if isinstance(outputs, tuple) else outputs
|
||||||
|
|
||||||
|
if hasattr(_tokenizer, "decode_audio"):
|
||||||
|
audio_data = _tokenizer.decode_audio(output_ids)
|
||||||
|
if isinstance(audio_data, bytes):
|
||||||
|
return audio_data
|
||||||
|
return audio_data.tobytes()
|
||||||
|
|
||||||
|
if hasattr(_model, "decode_audio"):
|
||||||
|
audio_data = _model.decode_audio(output_ids)
|
||||||
|
if isinstance(audio_data, bytes):
|
||||||
|
return audio_data
|
||||||
|
if isinstance(audio_data, torch.Tensor):
|
||||||
|
audio_np = audio_data.cpu().float().numpy()
|
||||||
|
return _numpy_to_wav_bytes(audio_np, 24000)
|
||||||
|
|
||||||
|
if isinstance(output_ids, torch.Tensor):
|
||||||
|
audio_np = output_ids.cpu().float().numpy()
|
||||||
|
if audio_np.ndim > 1:
|
||||||
|
audio_np = audio_np.flatten()
|
||||||
|
return _numpy_to_wav_bytes(audio_np, 24000)
|
||||||
|
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def _numpy_to_wav_bytes(audio: np.ndarray, sample_rate: int = 24000) -> bytes:
|
||||||
|
import wave
|
||||||
|
import struct
|
||||||
|
|
||||||
|
audio = np.clip(audio, -1.0, 1.0)
|
||||||
|
audio_int16 = (audio * 32767).astype(np.int16)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with wave.open(buf, "wb") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(sample_rate)
|
||||||
|
wf.writeframes(audio_int16.tobytes())
|
||||||
|
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes_to_numpy(
|
||||||
|
audio_bytes: bytes, target_sr: int = 24000
|
||||||
|
) -> tuple[np.ndarray, int]:
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
with io.BytesIO(audio_bytes) as buf:
|
||||||
|
data, sr = sf.read(buf)
|
||||||
|
|
||||||
|
if len(data.shape) > 1:
|
||||||
|
data = data.mean(axis=1)
|
||||||
|
|
||||||
|
if sr != target_sr:
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
data = librosa.resample(data, orig_sr=sr, target_sr=target_sr)
|
||||||
|
|
||||||
|
return data.astype(np.float32), target_sr
|
||||||
|
|
||||||
|
|
||||||
|
def unload_model():
|
||||||
|
global _model, _tokenizer
|
||||||
|
_model = None
|
||||||
|
_tokenizer = None
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_duration(path: str) -> Optional[float]:
|
||||||
|
cmd = [
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
|
path,
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return float(result.stdout.strip())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_audio(video_path: str, output_path: str, sample_rate: int = 16000) -> str:
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
video_path,
|
||||||
|
"-vn",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-ar",
|
||||||
|
str(sample_rate),
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
output_path,
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Audio extraction failed: {result.stderr}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def build_output_path(video_path: str) -> str:
|
||||||
|
base, ext = os.path.splitext(os.path.basename(video_path))
|
||||||
|
return f"{base}_en_da_audio{ext}"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from celery import Celery
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"bornetime",
|
||||||
|
broker=settings.redis_url,
|
||||||
|
backend=settings.redis_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
timezone="UTC",
|
||||||
|
enable_utc=True,
|
||||||
|
task_track_started=True,
|
||||||
|
task_acks_late=True,
|
||||||
|
worker_prefetch_multiplier=1,
|
||||||
|
)
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .celery_app import celery_app
|
||||||
|
from ..config import settings
|
||||||
|
from ..models.tables import Video, Segment, Job, VideoStatus, JobStatus
|
||||||
|
from ..services.tts import synthesize_segment, unload_model as unload_tts
|
||||||
|
from ..services.mixer import mix_audio
|
||||||
|
from ..services.video_utils import build_output_path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
engine = create_engine(settings.database_url.replace("+aiosqlite", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sync_session():
|
||||||
|
return Session(engine)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(bind=True, name="generate_audio")
|
||||||
|
def generate_audio(self, video_id: int, job_id: int = None):
|
||||||
|
db = _get_sync_session()
|
||||||
|
temp_files = []
|
||||||
|
audio_dir = None
|
||||||
|
try:
|
||||||
|
video = db.query(Video).filter(Video.id == video_id).first()
|
||||||
|
if not video:
|
||||||
|
raise ValueError(f"Video {video_id} not found")
|
||||||
|
|
||||||
|
video.status = VideoStatus.generating.value
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if job_id:
|
||||||
|
job = db.query(Job).filter(Job.id == job_id).first()
|
||||||
|
else:
|
||||||
|
job = Job(
|
||||||
|
video_id=video_id, job_type="generate", status=JobStatus.running.value
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
job.celery_task_id = self.request.id
|
||||||
|
job.status = JobStatus.running.value
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
segments = (
|
||||||
|
db.query(Segment)
|
||||||
|
.filter(Segment.video_id == video_id)
|
||||||
|
.order_by(Segment.start_time)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(segments)
|
||||||
|
audio_dir = os.path.join(settings.temp_dir, f"tts_{video_id}")
|
||||||
|
os.makedirs(audio_dir, exist_ok=True)
|
||||||
|
|
||||||
|
speaker_refs = {}
|
||||||
|
for seg in segments:
|
||||||
|
if seg.speaker_label and seg.speaker_label not in speaker_refs:
|
||||||
|
seg_audio_path = os.path.join(
|
||||||
|
settings.temp_dir,
|
||||||
|
f"ref_{video_id}_{seg.speaker_label.replace(' ', '_')}.wav",
|
||||||
|
)
|
||||||
|
temp_files.append(seg_audio_path)
|
||||||
|
_extract_segment_audio(
|
||||||
|
video.filepath, seg.start_time, seg.end_time, seg_audio_path
|
||||||
|
)
|
||||||
|
speaker_refs[seg.speaker_label] = {
|
||||||
|
"audio_path": seg_audio_path,
|
||||||
|
"text": seg.english_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Starting TTS for %d segments", total)
|
||||||
|
segment_data = []
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
ref = speaker_refs.get(seg.speaker_label, {})
|
||||||
|
audio_bytes = synthesize_segment(
|
||||||
|
text=seg.danish_text or seg.english_text or "",
|
||||||
|
reference_audio_path=ref.get("audio_path"),
|
||||||
|
reference_text=ref.get("text"),
|
||||||
|
speaker_label=seg.speaker_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
seg_audio_path = os.path.join(audio_dir, f"seg_{seg.id}.wav")
|
||||||
|
with open(seg_audio_path, "wb") as f:
|
||||||
|
f.write(audio_bytes)
|
||||||
|
temp_files.append(seg_audio_path)
|
||||||
|
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
danish_audio, sr = sf.read(seg_audio_path)
|
||||||
|
if len(danish_audio.shape) > 1:
|
||||||
|
danish_audio = danish_audio.mean(axis=1)
|
||||||
|
if sr != 24000:
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
danish_audio = librosa.resample(
|
||||||
|
danish_audio, orig_sr=sr, target_sr=24000
|
||||||
|
)
|
||||||
|
|
||||||
|
segment_data.append(
|
||||||
|
{
|
||||||
|
"start_time": seg.start_time,
|
||||||
|
"end_time": seg.end_time,
|
||||||
|
"danish_audio": danish_audio.astype(np.float32),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
pct = (i + 1) / total * 0.9
|
||||||
|
self.update_state(state="PROGRESS", meta={"progress": pct})
|
||||||
|
job.progress = pct
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("Mixing audio for video %d", video_id)
|
||||||
|
output_path = build_output_path(video.filepath)
|
||||||
|
mix_audio(video.filepath, segment_data, output_path)
|
||||||
|
|
||||||
|
video.output_path = output_path
|
||||||
|
video.status = VideoStatus.done.value
|
||||||
|
job.status = JobStatus.done.value
|
||||||
|
job.progress = 1.0
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
unload_tts()
|
||||||
|
logger.info("Generation complete for video %d", video_id)
|
||||||
|
return {"video_id": video_id, "output_path": output_path}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Generation failed for video %d: %s", video_id, e, exc_info=True)
|
||||||
|
video = db.query(Video).filter(Video.id == video_id).first()
|
||||||
|
if video:
|
||||||
|
video.status = VideoStatus.error.value
|
||||||
|
video.error_message = str(e)
|
||||||
|
if job_id:
|
||||||
|
job = db.query(Job).filter(Job.id == job_id).first()
|
||||||
|
else:
|
||||||
|
job = (
|
||||||
|
db.query(Job)
|
||||||
|
.filter(Job.video_id == video_id, Job.job_type == "generate")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if job:
|
||||||
|
job.status = JobStatus.failed.value
|
||||||
|
job.error_message = str(e)
|
||||||
|
db.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
for f in temp_files:
|
||||||
|
if os.path.exists(f):
|
||||||
|
try:
|
||||||
|
os.remove(f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if audio_dir and os.path.exists(audio_dir):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(audio_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_segment_audio(video_path: str, start: float, end: float, output_path: str):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
duration = end - start
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
video_path,
|
||||||
|
"-ss",
|
||||||
|
str(start),
|
||||||
|
"-t",
|
||||||
|
str(duration),
|
||||||
|
"-vn",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-ar",
|
||||||
|
"16000",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
output_path,
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, capture_output=True, check=True)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .celery_app import celery_app
|
||||||
|
from ..config import settings
|
||||||
|
from ..models.tables import Video, Segment, Job, VideoStatus, JobStatus
|
||||||
|
from ..services.diarization import diarize, unload_model as unload_diarization
|
||||||
|
from ..services.stt import transcribe_segment, unload_model as unload_stt
|
||||||
|
from ..services.translator import translate, unload_model as unload_translate
|
||||||
|
from ..services.video_utils import extract_audio
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
engine = create_engine(settings.database_url.replace("+aiosqlite", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sync_session():
|
||||||
|
return Session(engine)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(bind=True, name="process_video")
|
||||||
|
def process_video(self, video_id: int, job_id: int = None):
|
||||||
|
db = _get_sync_session()
|
||||||
|
temp_files = []
|
||||||
|
try:
|
||||||
|
video = db.query(Video).filter(Video.id == video_id).first()
|
||||||
|
if not video:
|
||||||
|
raise ValueError(f"Video {video_id} not found")
|
||||||
|
|
||||||
|
video.status = VideoStatus.processing.value
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if job_id:
|
||||||
|
job = db.query(Job).filter(Job.id == job_id).first()
|
||||||
|
else:
|
||||||
|
job = Job(
|
||||||
|
video_id=video_id, job_type="process", status=JobStatus.running.value
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
self.update_state(state="PROGRESS", meta={"progress": 0.0})
|
||||||
|
job.celery_task_id = self.request.id
|
||||||
|
job.status = JobStatus.running.value
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
audio_path = os.path.join(settings.temp_dir, f"audio_{video_id}.wav")
|
||||||
|
temp_files.append(audio_path)
|
||||||
|
extract_audio(video.filepath, audio_path)
|
||||||
|
logger.info("Extracted audio for video %d", video_id)
|
||||||
|
|
||||||
|
self.update_state(state="PROGRESS", meta={"progress": 0.10})
|
||||||
|
job.progress = 0.10
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
segments = diarize(audio_path)
|
||||||
|
if not segments:
|
||||||
|
raise RuntimeError("No speech segments detected in video")
|
||||||
|
logger.info("Diarized %d segments for video %d", len(segments), video_id)
|
||||||
|
|
||||||
|
unload_diarization()
|
||||||
|
|
||||||
|
self.update_state(state="PROGRESS", meta={"progress": 0.20})
|
||||||
|
job.progress = 0.20
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
total = len(segments)
|
||||||
|
|
||||||
|
logger.info("Starting STT for %d segments", total)
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
en_text = transcribe_segment(audio_path, seg["start_time"], seg["end_time"])
|
||||||
|
seg["english_text"] = en_text
|
||||||
|
pct = 0.20 + (i + 1) / total * 0.35
|
||||||
|
self.update_state(state="PROGRESS", meta={"progress": pct})
|
||||||
|
job.progress = pct
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
unload_stt()
|
||||||
|
logger.info("STT complete, unloaded model")
|
||||||
|
|
||||||
|
logger.info("Starting translation for %d segments", total)
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
en_text = seg.get("english_text", "")
|
||||||
|
da_text = translate(en_text) if en_text else ""
|
||||||
|
seg["danish_text"] = da_text
|
||||||
|
|
||||||
|
pct = 0.55 + (i + 1) / total * 0.35
|
||||||
|
self.update_state(state="PROGRESS", meta={"progress": pct})
|
||||||
|
job.progress = pct
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
unload_translate()
|
||||||
|
logger.info("Translation complete, unloaded model")
|
||||||
|
|
||||||
|
for seg in segments:
|
||||||
|
db_seg = Segment(
|
||||||
|
video_id=video_id,
|
||||||
|
speaker_label=seg["speaker_label"],
|
||||||
|
start_time=seg["start_time"],
|
||||||
|
end_time=seg["end_time"],
|
||||||
|
english_text=seg.get("english_text", ""),
|
||||||
|
danish_text=seg.get("danish_text", ""),
|
||||||
|
status="done",
|
||||||
|
)
|
||||||
|
db.add(db_seg)
|
||||||
|
|
||||||
|
video.status = VideoStatus.review.value
|
||||||
|
job.status = JobStatus.done.value
|
||||||
|
job.progress = 1.0
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("Processing complete for video %d", video_id)
|
||||||
|
return {"video_id": video_id, "status": "review"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Processing failed for video %d: %s", video_id, e, exc_info=True)
|
||||||
|
video = db.query(Video).filter(Video.id == video_id).first()
|
||||||
|
if video:
|
||||||
|
video.status = VideoStatus.error.value
|
||||||
|
video.error_message = str(e)
|
||||||
|
if job_id:
|
||||||
|
job = db.query(Job).filter(Job.id == job_id).first()
|
||||||
|
else:
|
||||||
|
job = (
|
||||||
|
db.query(Job)
|
||||||
|
.filter(Job.video_id == video_id, Job.job_type == "process")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if job:
|
||||||
|
job.status = JobStatus.failed.value
|
||||||
|
job.error_message = str(e)
|
||||||
|
db.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
for f in temp_files:
|
||||||
|
if os.path.exists(f):
|
||||||
|
try:
|
||||||
|
os.remove(f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
sqlalchemy[asyncio]>=2.0.0
|
||||||
|
aiosqlite>=0.20.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
celery>=5.4.0
|
||||||
|
redis>=5.0.0
|
||||||
|
python-multipart>=0.0.12
|
||||||
|
ffmpeg-python>=0.2.0
|
||||||
|
torch>=2.4.0
|
||||||
|
transformers>=4.51.0
|
||||||
|
boson_multimodal
|
||||||
|
soundfile>=0.12.0
|
||||||
|
librosa>=0.10.0
|
||||||
|
pyannote.audio>=3.0.0
|
||||||
|
llama-cpp-python>=0.3.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
aiofiles>=24.0.0
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data:z
|
||||||
|
networks:
|
||||||
|
- bornetime
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app:z
|
||||||
|
- ./data/videos:/data/videos:z
|
||||||
|
- ./data/app:/data/app:z
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite+aiosqlite:////data/app/bornetime.db
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- VIDEO_DIR=/data/videos
|
||||||
|
- HF_TOKEN=${HF_TOKEN:-}
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- bornetime
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.worker.gguf
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app:z
|
||||||
|
- ./data/videos:/data/videos:z
|
||||||
|
- ./data/app:/data/app:z
|
||||||
|
- ./data/hf_cache:/data/hf_cache:z
|
||||||
|
- ./models:/models:z
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite+aiosqlite:////data/app/bornetime.db
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- VIDEO_DIR=/data/videos
|
||||||
|
- HF_TOKEN=${HF_TOKEN:-}
|
||||||
|
- HUGGINGFACE_TOKEN=${HF_TOKEN:-}
|
||||||
|
- TRANSLATE_MODEL_PATH=/models/translategemma-12b-q8_0.gguf
|
||||||
|
- ROCR_VISIBLE_DEVICES=0,1
|
||||||
|
- PYTORCH_HIP_ALLOC_CONF=expandable_segments:True
|
||||||
|
devices:
|
||||||
|
- /dev/kfd:/dev/kfd:z
|
||||||
|
- /dev/dri:/dev/dri:z
|
||||||
|
group_add:
|
||||||
|
- video
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- bornetime
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app:z
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- bornetime
|
||||||
|
|
||||||
|
frontend-prod:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
profiles:
|
||||||
|
- production
|
||||||
|
networks:
|
||||||
|
- bornetime
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
|
hf_cache:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bornetime:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Børnetime — Video Translation</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>🎬</text></svg>" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "bornetime",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script>
|
||||||
|
import './app.css';
|
||||||
|
import Dashboard from './routes/Dashboard.svelte';
|
||||||
|
import VideoDetail from './routes/VideoDetail.svelte';
|
||||||
|
import TranscriptEditor from './routes/TranscriptEditor.svelte';
|
||||||
|
import JobQueue from './routes/JobQueue.svelte';
|
||||||
|
|
||||||
|
let route = $state('dashboard');
|
||||||
|
let params = $state({});
|
||||||
|
|
||||||
|
function navigate(path) {
|
||||||
|
const parts = path.split('/');
|
||||||
|
route = parts[0] || 'dashboard';
|
||||||
|
params = {};
|
||||||
|
if (parts[1]) params.id = parts[1];
|
||||||
|
if (parts[2]) params.sub = parts[2];
|
||||||
|
window.history.pushState(null, '', '#' + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePopState() {
|
||||||
|
const hash = window.location.hash.slice(1) || 'dashboard';
|
||||||
|
const parts = hash.split('/');
|
||||||
|
route = parts[0] || 'dashboard';
|
||||||
|
params = {};
|
||||||
|
if (parts[1]) params.id = parts[1];
|
||||||
|
if (parts[2]) params.sub = parts[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
handlePopState();
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<nav class="topbar">
|
||||||
|
<div class="topbar-inner">
|
||||||
|
<button class="logo" onclick={() => navigate('dashboard')}>
|
||||||
|
<span class="logo-icon">🎬</span>
|
||||||
|
<span class="logo-text">Børnetime</span>
|
||||||
|
</button>
|
||||||
|
<div class="nav-links">
|
||||||
|
<button
|
||||||
|
class="nav-link"
|
||||||
|
class:active={route === 'dashboard'}
|
||||||
|
onclick={() => navigate('dashboard')}
|
||||||
|
>Videos</button>
|
||||||
|
<button
|
||||||
|
class="nav-link"
|
||||||
|
class:active={route === 'jobs'}
|
||||||
|
onclick={() => navigate('jobs')}
|
||||||
|
>Jobs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
{#if route === 'dashboard'}
|
||||||
|
<Dashboard {navigate} />
|
||||||
|
{:else if route === 'video'}
|
||||||
|
<VideoDetail id={params.id} {navigate} />
|
||||||
|
{:else if route === 'transcript'}
|
||||||
|
<TranscriptEditor id={params.id} {navigate} />
|
||||||
|
{:else if route === 'jobs'}
|
||||||
|
<JobQueue {navigate} />
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-shell {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-inner {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0f1117;
|
||||||
|
--bg-secondary: #1a1d27;
|
||||||
|
--bg-tertiary: #242736;
|
||||||
|
--bg-hover: #2a2d3d;
|
||||||
|
--border: #2e3348;
|
||||||
|
--text-primary: #e4e6f0;
|
||||||
|
--text-secondary: #8b8fa3;
|
||||||
|
--text-muted: #565a6e;
|
||||||
|
--accent: #6c5ce7;
|
||||||
|
--accent-hover: #7c6ff0;
|
||||||
|
--accent-subtle: rgba(108, 92, 231, 0.15);
|
||||||
|
--green: #00d68f;
|
||||||
|
--green-bg: rgba(0, 214, 143, 0.12);
|
||||||
|
--yellow: #ffa726;
|
||||||
|
--yellow-bg: rgba(255, 167, 38, 0.12);
|
||||||
|
--red: #ff5252;
|
||||||
|
--red-bg: rgba(255, 82, 82, 0.12);
|
||||||
|
--blue: #4fc3f7;
|
||||||
|
--blue-bg: rgba(79, 195, 247, 0.12);
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const url = `${API_BASE}${path}`;
|
||||||
|
const config = {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.body && typeof config.body === 'object' && !(config.body instanceof FormData)) {
|
||||||
|
config.body = JSON.stringify(config.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.body instanceof FormData) {
|
||||||
|
delete config.headers['Content-Type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, config);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
listVideos: () => request('/videos'),
|
||||||
|
getVideo: (id) => request(`/videos/${id}`),
|
||||||
|
uploadVideo: (file) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
return request('/videos/upload', { method: 'POST', body: fd });
|
||||||
|
},
|
||||||
|
deleteVideo: (id) => request(`/videos/${id}`, { method: 'DELETE' }),
|
||||||
|
processVideo: (id) => request(`/videos/${id}/process`, { method: 'POST' }),
|
||||||
|
getTranscript: (id) => request(`/videos/${id}/transcript`),
|
||||||
|
updateSegment: (videoId, segmentId, data) =>
|
||||||
|
request(`/videos/${videoId}/transcript/${segmentId}`, { method: 'PUT', body: data }),
|
||||||
|
generateAudio: (id) => request(`/videos/${id}/generate`, { method: 'POST' }),
|
||||||
|
|
||||||
|
listJobs: () => request('/jobs'),
|
||||||
|
getJob: (id) => request(`/jobs/${id}`),
|
||||||
|
cancelJob: (id) => request(`/jobs/${id}/cancel`, { method: 'POST' }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script>
|
||||||
|
let { src = '', segments = [] } = $props();
|
||||||
|
let videoEl = $state(null);
|
||||||
|
let currentTime = $state(0);
|
||||||
|
|
||||||
|
function onTimeUpdate() {
|
||||||
|
if (videoEl) currentTime = videoEl.currentTime;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="player-wrapper">
|
||||||
|
<video
|
||||||
|
bind:this={videoEl}
|
||||||
|
controls
|
||||||
|
width="100%"
|
||||||
|
{src}
|
||||||
|
ontimeupdate={onTimeUpdate}
|
||||||
|
>
|
||||||
|
Your browser does not support video playback.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.player-wrapper {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
display: block;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script>
|
||||||
|
let { value = 0 } = $props();
|
||||||
|
let pct = $derived(Math.min(Math.max(value * 100, 0), 100));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" style="width: {pct}%"></div>
|
||||||
|
<span class="progress-label">{Math.round(pct)}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.progress-track {
|
||||||
|
height: 20px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), #8b7cf7);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script>
|
||||||
|
let { segments = [], duration = 0 } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="timeline-track">
|
||||||
|
{#each segments as seg}
|
||||||
|
<div
|
||||||
|
class="segment-bar"
|
||||||
|
style="left: {(seg.start_time / duration) * 100}%; width: {((seg.end_time - seg.start_time) / duration) * 100}%"
|
||||||
|
title="{seg.speaker_label}: {seg.english_text?.slice(0, 60)}"
|
||||||
|
>
|
||||||
|
<span class="bar-label">{seg.speaker_label}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.timeline-track {
|
||||||
|
height: 32px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.7;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
min-width: 20px;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-bar:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script>
|
||||||
|
let { video, navigate, ondelete } = $props();
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
uploaded: 'Uploaded',
|
||||||
|
queued: 'Queued',
|
||||||
|
processing: 'Processing',
|
||||||
|
review: 'Review',
|
||||||
|
generating: 'Generating',
|
||||||
|
done: 'Done',
|
||||||
|
error: 'Error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
uploaded: 'var(--text-muted)',
|
||||||
|
queued: 'var(--blue)',
|
||||||
|
processing: 'var(--yellow)',
|
||||||
|
review: 'var(--accent)',
|
||||||
|
generating: 'var(--yellow)',
|
||||||
|
done: 'var(--green)',
|
||||||
|
error: 'var(--red)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBg = {
|
||||||
|
uploaded: 'transparent',
|
||||||
|
queued: 'var(--blue-bg)',
|
||||||
|
processing: 'var(--yellow-bg)',
|
||||||
|
review: 'var(--accent-subtle)',
|
||||||
|
generating: 'var(--yellow-bg)',
|
||||||
|
done: 'var(--green-bg)',
|
||||||
|
error: 'var(--red-bg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(sec) {
|
||||||
|
if (!sec) return '—';
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card" onclick={() => navigate(`video/${video.id}`)}>
|
||||||
|
<div class="card-thumb">
|
||||||
|
<div class="thumb-placeholder">🎬</div>
|
||||||
|
<span class="duration">{formatDuration(video.duration)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title" title={video.filename}>{video.filename}</h3>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
style="background: {statusBg[video.status] || 'transparent'}; color: {statusColors[video.status] || 'var(--text-muted)'}; border-color: {statusColors[video.status] || 'var(--border)'};"
|
||||||
|
>
|
||||||
|
{statusLabels[video.status] || video.status}
|
||||||
|
</span>
|
||||||
|
<span class="date">{formatDate(video.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{#if video.status === 'error' && video.error_message}
|
||||||
|
<p class="error-msg">{video.error_message}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button class="delete-btn" onclick={(e) => { e.stopPropagation(); ondelete?.(); }} title="Delete">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumb {
|
||||||
|
height: 140px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder {
|
||||||
|
font-size: 40px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--red);
|
||||||
|
margin-top: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: var(--red);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import App from './App.svelte';
|
||||||
|
import { mount } from 'svelte';
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
import VideoCard from '../lib/components/VideoCard.svelte';
|
||||||
|
|
||||||
|
let { navigate } = $props();
|
||||||
|
|
||||||
|
let videos = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let uploading = $state(false);
|
||||||
|
let error = $state(null);
|
||||||
|
let dragOver = $state(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const data = await api.listVideos();
|
||||||
|
videos = data.videos;
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(file) {
|
||||||
|
if (!file) return;
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
await api.uploadVideo(file);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOver = false;
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (file) handleUpload(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
dragOver = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelected(e) {
|
||||||
|
const file = e.target?.files?.[0];
|
||||||
|
if (file) handleUpload(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id) {
|
||||||
|
await api.deleteVideo(id);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Videos</h1>
|
||||||
|
<button class="btn-refresh" onclick={load}>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="upload-zone"
|
||||||
|
class:drag-over={dragOver}
|
||||||
|
ondragover={onDragOver}
|
||||||
|
ondragleave={onDragLeave}
|
||||||
|
ondrop={onDrop}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => document.getElementById('file-upload')?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
type="file"
|
||||||
|
accept=".mp4,.mkv,.mov,.avi,.webm,.m4v"
|
||||||
|
hidden
|
||||||
|
onchange={onFileSelected}
|
||||||
|
/>
|
||||||
|
{#if uploading}
|
||||||
|
<span class="upload-hint">Uploading…</span>
|
||||||
|
{:else}
|
||||||
|
<span class="upload-icon">⏫</span>
|
||||||
|
<span class="upload-hint">Drop a video here or click to upload</span>
|
||||||
|
<span class="upload-formats">MP4, MKV, MOV, AVI, WebM, M4V</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Loading videos…</div>
|
||||||
|
{:else if videos.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<span class="empty-icon">🎥</span>
|
||||||
|
<p>No videos yet. Upload one to get started.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid">
|
||||||
|
{#each videos as video}
|
||||||
|
<VideoCard {video} {navigate} ondelete={() => onDelete(video.id)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover, .upload-zone.drag-over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-formats {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background: var(--red-bg);
|
||||||
|
color: var(--red);
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
import ProgressBar from '../lib/components/ProgressBar.svelte';
|
||||||
|
|
||||||
|
let { navigate } = $props();
|
||||||
|
|
||||||
|
let jobs = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let polling = $state(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await api.listJobs();
|
||||||
|
jobs = data.jobs;
|
||||||
|
} catch {} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel(jobId, e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.cancelJob(jobId);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
load();
|
||||||
|
polling = setInterval(load, 3000);
|
||||||
|
return () => clearInterval(polling);
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
pending: 'var(--blue)',
|
||||||
|
running: 'var(--yellow)',
|
||||||
|
done: 'var(--green)',
|
||||||
|
failed: 'var(--red)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBg = {
|
||||||
|
pending: 'var(--blue-bg)',
|
||||||
|
running: 'var(--yellow-bg)',
|
||||||
|
done: 'var(--green-bg)',
|
||||||
|
failed: 'var(--red-bg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
return new Date(d).toLocaleString('en-GB');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="queue">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Jobs</h1>
|
||||||
|
<button class="btn-refresh" onclick={load}>↻</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Loading…</div>
|
||||||
|
{:else if jobs.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<span class="empty-icon">📋</span>
|
||||||
|
<p>No jobs yet. Process a video to see jobs here.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Video</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each jobs as job}
|
||||||
|
<tr onclick={() => navigate(`video/${job.video_id}`)}>
|
||||||
|
<td class="cell-id">#{job.id}</td>
|
||||||
|
<td><span class="job-type">{job.job_type}</span></td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
style="background: {statusBg[job.status] || 'transparent'}; color: {statusColors[job.status] || 'var(--text-muted)'};"
|
||||||
|
>
|
||||||
|
{job.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="cell-progress">
|
||||||
|
<ProgressBar value={job.progress} />
|
||||||
|
</td>
|
||||||
|
<td class="cell-video">#{job.video_id}</td>
|
||||||
|
<td class="cell-date">{formatDate(job.created_at)}</td>
|
||||||
|
<td class="cell-error">
|
||||||
|
{#if job.error_message}
|
||||||
|
<span class="error-text" title={job.error_message}>⚠ {job.error_message.slice(0, 40)}…</span>
|
||||||
|
{:else if job.status === 'pending' || job.status === 'running'}
|
||||||
|
<button class="cancel-btn" onclick={(e) => handleCancel(job.id, e)}>✕ Cancel</button>
|
||||||
|
{:else}—{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.queue {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-refresh:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-id {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-type {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-progress {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-video {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-date {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-error {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--yellow);
|
||||||
|
color: var(--yellow);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background: var(--yellow-bg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
|
let { id, navigate } = $props();
|
||||||
|
|
||||||
|
let segments = $state([]);
|
||||||
|
let video = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let error = $state(null);
|
||||||
|
let successMsg = $state(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [v, t] = await Promise.all([api.getVideo(id), api.getTranscript(id)]);
|
||||||
|
video = v;
|
||||||
|
segments = t.segments;
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSegment(seg) {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const updated = await api.updateSegment(id, seg.id, {
|
||||||
|
english_text: seg.english_text,
|
||||||
|
danish_text: seg.danish_text,
|
||||||
|
speaker_label: seg.speaker_label,
|
||||||
|
});
|
||||||
|
Object.assign(seg, updated);
|
||||||
|
successMsg = 'Saved';
|
||||||
|
setTimeout(() => successMsg = null, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
await api.generateAudio(id);
|
||||||
|
navigate(`video/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(sec) {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
const ms = Math.floor((sec % 1) * 100);
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="editor">
|
||||||
|
<button class="back" onclick={() => navigate(`video/${id}`)}>← Back to Video</button>
|
||||||
|
|
||||||
|
<div class="editor-header">
|
||||||
|
<h1>{video?.filename || 'Transcript'}</h1>
|
||||||
|
{#if video?.status === 'review'}
|
||||||
|
<button class="btn accent" onclick={handleGenerate}>
|
||||||
|
🎙 Confirm & Generate Audio
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if successMsg}
|
||||||
|
<span class="success-toast">{successMsg}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Loading transcript…</div>
|
||||||
|
{:else if segments.length === 0}
|
||||||
|
<div class="empty">No segments available.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="segment-list">
|
||||||
|
{#each segments as seg}
|
||||||
|
<div class="segment">
|
||||||
|
<div class="segment-header">
|
||||||
|
<span class="timestamp">{formatTime(seg.start_time)} → {formatTime(seg.end_time)}</span>
|
||||||
|
<span class="duration">({(seg.end_time - seg.start_time).toFixed(1)}s)</span>
|
||||||
|
<span class="speaker-badge">{seg.speaker_label || '?'}</span>
|
||||||
|
<span class="seg-status done">✓ Transcribed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="segment-texts">
|
||||||
|
<div class="text-block">
|
||||||
|
<label>English</label>
|
||||||
|
<textarea
|
||||||
|
bind:value={seg.english_text}
|
||||||
|
rows="2"
|
||||||
|
onchange={() => saveSegment(seg)}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="text-block">
|
||||||
|
<label>Danish</label>
|
||||||
|
<textarea
|
||||||
|
bind:value={seg.danish_text}
|
||||||
|
rows="2"
|
||||||
|
onchange={() => saveSegment(seg)}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.editor {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.accent {
|
||||||
|
background: var(--green);
|
||||||
|
border-color: var(--green);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.accent:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-toast {
|
||||||
|
background: var(--green-bg);
|
||||||
|
color: var(--green);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background: var(--red-bg);
|
||||||
|
color: var(--red);
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--green);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-texts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-block label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-block textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-block textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
<script>
|
||||||
|
import { api } from '../lib/api.js';
|
||||||
|
|
||||||
|
let { id, navigate } = $props();
|
||||||
|
|
||||||
|
let video = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state(null);
|
||||||
|
let polling = $state(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
video = await api.getVideo(id);
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (polling) return;
|
||||||
|
polling = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
video = await api.getVideo(id);
|
||||||
|
if (video.status === 'review' || video.status === 'done' || video.status === 'error') {
|
||||||
|
clearInterval(polling);
|
||||||
|
polling = null;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProcess() {
|
||||||
|
await api.processVideo(id);
|
||||||
|
video = await api.getVideo(id);
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
await api.generateAudio(id);
|
||||||
|
video = await api.getVideo(id);
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await api.deleteVideo(id);
|
||||||
|
navigate('dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
try {
|
||||||
|
const jobs = await api.listJobs();
|
||||||
|
const activeJob = jobs.jobs.find(j => j.video_id == id && (j.status === 'pending' || j.status === 'running'));
|
||||||
|
if (activeJob) {
|
||||||
|
await api.cancelJob(activeJob.id);
|
||||||
|
video = await api.getVideo(id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
load();
|
||||||
|
return () => { if (polling) clearInterval(polling); };
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (video && (video.status === 'processing' || video.status === 'generating' || video.status === 'queued')) {
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function videoUrl(id) {
|
||||||
|
return `/api/videos/${id}/file`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputUrl(path) {
|
||||||
|
return path ? `/api/videos/file/${encodeURIComponent(path)}` : null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<button class="back" onclick={() => navigate('dashboard')}>← Back</button>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Loading…</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="alert error">{error}</div>
|
||||||
|
{:else if video}
|
||||||
|
<div class="detail-header">
|
||||||
|
<h1>{video.filename}</h1>
|
||||||
|
<div class="actions">
|
||||||
|
{#if video.status === 'uploaded' || video.status === 'error'}
|
||||||
|
<button class="btn primary" onclick={handleProcess}>
|
||||||
|
▶ Process
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if video.status === 'review'}
|
||||||
|
<button class="btn primary" onclick={() => navigate(`transcript/${video.id}`)}>
|
||||||
|
✏ Review Transcript
|
||||||
|
</button>
|
||||||
|
<button class="btn accent" onclick={handleGenerate}>
|
||||||
|
🎙 Generate Audio
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if video.status === 'done'}
|
||||||
|
<button class="btn primary" onclick={() => navigate(`transcript/${video.id}`)}>
|
||||||
|
✏ View Transcript
|
||||||
|
</button>
|
||||||
|
<a class="btn accent" href={outputUrl(video.output_path)} download>
|
||||||
|
⬇ Download
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if video.status === 'processing' || video.status === 'generating' || video.status === 'queued'}
|
||||||
|
<span class="processing-badge">
|
||||||
|
{video.status === 'queued' ? '⏳ Queued' : '⏳ Processing…'}
|
||||||
|
</span>
|
||||||
|
<button class="btn cancel" onclick={handleCancel}>✕ Cancel</button>
|
||||||
|
{/if}
|
||||||
|
<button class="btn danger" onclick={handleDelete}>🗑 Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="meta-label">Status</span>
|
||||||
|
<span class="meta-value badge badge-{video.status}">{video.status}</span>
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="meta-label">Duration</span>
|
||||||
|
<span class="meta-value">{video.duration ? Math.floor(video.duration / 60) + 'm ' + Math.floor(video.duration % 60) + 's' : '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="meta-label">Created</span>
|
||||||
|
<span class="meta-value">{new Date(video.created_at).toLocaleString('en-GB')}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if video.status === 'error' && video.error_message}
|
||||||
|
<div class="alert error">Error: {video.error_message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="video-player">
|
||||||
|
<video controls width="100%" src={videoUrl(video.id)}>
|
||||||
|
Your browser does not support video playback.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.detail {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.accent {
|
||||||
|
background: var(--green);
|
||||||
|
border-color: var(--green);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.accent:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
border-color: var(--red);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger:hover {
|
||||||
|
background: var(--red-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.cancel {
|
||||||
|
border-color: var(--yellow);
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.cancel:hover {
|
||||||
|
background: var(--yellow-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-badge {
|
||||||
|
background: var(--yellow-bg);
|
||||||
|
color: var(--yellow);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-uploaded { color: var(--text-muted); }
|
||||||
|
.badge-queued { color: var(--blue); }
|
||||||
|
.badge-processing { color: var(--yellow); }
|
||||||
|
.badge-review { color: var(--accent); }
|
||||||
|
.badge-generating { color: var(--yellow); }
|
||||||
|
.badge-done { color: var(--green); }
|
||||||
|
.badge-error { color: var(--red); }
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background: var(--red-bg);
|
||||||
|
color: var(--red);
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player video {
|
||||||
|
display: block;
|
||||||
|
max-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://api:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
Vendored
Reference in New Issue
Block a user