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:
2026-07-10 22:42:23 +02:00
commit 6851b26923
43 changed files with 3390 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
__pycache__
*.pyc
.env
.git
dist
data
test
+9
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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"]
+38
View File
@@ -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;"]
+23
View File
@@ -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"]
+34
View File
@@ -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"]
View File
View File
+54
View File
@@ -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")
+218
View File
@@ -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")
+34
View File
@@ -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()
+43
View File
@@ -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"}
View File
+84
View File
@@ -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()
+110
View File
@@ -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}")
+141
View File
@@ -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()
+60
View File
@@ -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()
+256
View File
@@ -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()
+47
View File
@@ -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}"
View File
+19
View File
@@ -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,
)
+189
View File
@@ -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)
+144
View File
@@ -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()
+19
View File
@@ -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
+96
View File
@@ -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
+14
View File
@@ -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>
+16
View File
@@ -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"
}
}
+141
View File
@@ -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>
+73
View File
@@ -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;
}
+44
View File
@@ -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' }),
};
+35
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
import App from './App.svelte';
import { mount } from 'svelte';
const app = mount(App, {
target: document.getElementById('app'),
});
export default app;
+227
View File
@@ -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>
+265
View File
@@ -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>
+286
View File
@@ -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>
+321
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};
+18
View File
@@ -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',
},
});
View File