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,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")
|
||||
Reference in New Issue
Block a user