6851b26923
FastAPI + Celery + Svelte app for English→Danish video translation with voice cloning. Includes diarization, STT, translation, TTS, and audio mixing pipeline.
219 lines
7.4 KiB
Python
219 lines
7.4 KiB
Python
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")
|