initialize

This commit is contained in:
2026-04-09 08:47:37 +00:00
commit b80e97799f
22 changed files with 3015 additions and 0 deletions

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@

12
backend/app/config.py Normal file
View File

@@ -0,0 +1,12 @@
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[2]
STORAGE_DIR = ROOT_DIR / "storage"
UPLOAD_DIR = STORAGE_DIR / "uploads"
PROCESSED_DIR = STORAGE_DIR / "processed"
HLS_DIR = STORAGE_DIR / "hls"
COVER_DIR = STORAGE_DIR / "covers"
for directory in (STORAGE_DIR, UPLOAD_DIR, PROCESSED_DIR, HLS_DIR, COVER_DIR):
directory.mkdir(parents=True, exist_ok=True)

71
backend/app/main.py Normal file
View File

@@ -0,0 +1,71 @@
from typing import Dict, List
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import COVER_DIR, HLS_DIR, PROCESSED_DIR, STORAGE_DIR, UPLOAD_DIR
from app.schemas import TaskCreateRequest, TaskResponse, UploadResponse
from app.services.storage import get_upload_path, save_upload
from app.services.tasks import task_manager
app = FastAPI(title="VPlatform API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/media/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
app.mount("/media/processed", StaticFiles(directory=PROCESSED_DIR), name="processed")
app.mount("/media/hls", StaticFiles(directory=HLS_DIR), name="hls")
app.mount("/media/covers", StaticFiles(directory=COVER_DIR), name="covers")
app.mount("/media", StaticFiles(directory=STORAGE_DIR), name="media")
@app.on_event("startup")
def start_worker() -> None:
task_manager.start()
@app.get("/api/health")
def health() -> Dict[str, str]:
return {"status": "ok"}
@app.post("/api/upload", response_model=UploadResponse)
async def upload_video(file: UploadFile = File(...)) -> UploadResponse:
if not file.filename:
raise HTTPException(status_code=400, detail="missing filename")
file_id, original_name = save_upload(file)
return UploadResponse(
file_id=file_id,
original_name=original_name,
file_url=f"/media/uploads/{file_id}",
)
@app.post("/api/tasks", response_model=TaskResponse)
def create_task(payload: TaskCreateRequest) -> TaskResponse:
if not get_upload_path(payload.file_id).exists():
raise HTTPException(status_code=404, detail="uploaded file not found")
record = task_manager.create_task(payload)
return TaskResponse(**record.__dict__)
@app.get("/api/tasks", response_model=List[TaskResponse])
def list_tasks() -> List[TaskResponse]:
return [TaskResponse(**task.__dict__) for task in task_manager.list_tasks()]
@app.get("/api/tasks/{task_id}", response_model=TaskResponse)
def get_task(task_id: str) -> TaskResponse:
task = task_manager.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return TaskResponse(**task.__dict__)

36
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,36 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class UploadResponse(BaseModel):
file_id: str
original_name: str
file_url: str
class TaskCreateRequest(BaseModel):
file_id: str = Field(..., description="Uploaded file identifier")
clip_seconds: Optional[int] = Field(default=8, ge=1, le=60)
transcode_mp4: bool = True
generate_cover: bool = True
generate_hls: bool = True
watermark_text: Optional[str] = Field(default="VPlatform Demo", max_length=32)
class TaskResult(BaseModel):
mp4_url: Optional[str] = None
cover_url: Optional[str] = None
hls_url: Optional[str] = None
log: List[str] = Field(default_factory=list)
class TaskResponse(BaseModel):
task_id: str
status: str
file_id: str
created_at: str
updated_at: str
options: TaskCreateRequest
result: TaskResult
error: Optional[str] = None

View File

@@ -0,0 +1,104 @@
import subprocess
from pathlib import Path
from typing import List, Optional
from app.config import COVER_DIR, HLS_DIR, PROCESSED_DIR
class FFmpegError(RuntimeError):
pass
def run_ffmpeg(command: List[str]) -> None:
completed = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if completed.returncode != 0:
raise FFmpegError(completed.stderr.strip() or "ffmpeg command failed")
def build_filter(watermark_text: Optional[str]) -> Optional[str]:
if not watermark_text:
return None
escaped = watermark_text.replace(":", r"\:").replace("'", r"\\'")
return (
"drawtext=text='"
f"{escaped}"
"':fontcolor=white:fontsize=28:box=1:boxcolor=black@0.45:boxborderw=6:x=w-tw-24:y=h-th-24"
)
def transcode_to_mp4(
source: Path, task_id: str, clip_seconds: Optional[int], watermark_text: Optional[str]
) -> Path:
output = PROCESSED_DIR / f"{task_id}.mp4"
command = ["ffmpeg", "-y", "-i", str(source)]
if clip_seconds:
command.extend(["-t", str(clip_seconds)])
video_filter = build_filter(watermark_text)
if video_filter:
command.extend(["-vf", video_filter])
command.extend(
[
"-c:v",
"libx264",
"-preset",
"veryfast",
"-c:a",
"aac",
"-movflags",
"+faststart",
str(output),
]
)
run_ffmpeg(command)
return output
def generate_cover(source: Path, task_id: str) -> Path:
output = COVER_DIR / f"{task_id}.jpg"
command = [
"ffmpeg",
"-y",
"-ss",
"00:00:01",
"-i",
str(source),
"-frames:v",
"1",
str(output),
]
run_ffmpeg(command)
return output
def generate_hls(source_mp4: Path, task_id: str) -> Path:
task_dir = HLS_DIR / task_id
task_dir.mkdir(parents=True, exist_ok=True)
playlist = task_dir / "index.m3u8"
segment_pattern = task_dir / "segment_%03d.ts"
command = [
"ffmpeg",
"-y",
"-i",
str(source_mp4),
"-codec:v",
"libx264",
"-codec:a",
"aac",
"-hls_time",
"4",
"-hls_playlist_type",
"vod",
"-hls_segment_filename",
str(segment_pattern),
str(playlist),
]
run_ffmpeg(command)
return playlist

View File

@@ -0,0 +1,23 @@
import shutil
import uuid
from pathlib import Path
from typing import Tuple
from fastapi import UploadFile
from app.config import UPLOAD_DIR
def save_upload(file: UploadFile) -> Tuple[str, str]:
suffix = Path(file.filename or "").suffix.lower() or ".mp4"
file_id = f"{uuid.uuid4().hex}{suffix}"
destination = UPLOAD_DIR / file_id
with destination.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return file_id, file.filename or file_id
def get_upload_path(file_id: str) -> Path:
return UPLOAD_DIR / file_id

View File

@@ -0,0 +1,124 @@
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from queue import Queue
from typing import Dict, List, Optional
from app.schemas import TaskCreateRequest, TaskResult
from app.services.media import generate_cover, generate_hls, transcode_to_mp4
from app.services.storage import get_upload_path
def now_iso() -> str:
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
@dataclass
class TaskRecord:
task_id: str
file_id: str
options: TaskCreateRequest
status: str = "queued"
result: TaskResult = field(default_factory=TaskResult)
error: Optional[str] = None
created_at: str = field(default_factory=now_iso)
updated_at: str = field(default_factory=now_iso)
def touch(self) -> None:
self.updated_at = now_iso()
class TaskManager:
def __init__(self) -> None:
self.tasks: Dict[str, TaskRecord] = {}
self.queue: Queue = Queue()
self.lock = threading.Lock()
self.worker = threading.Thread(target=self._worker_loop, daemon=True)
self.worker_started = False
def start(self) -> None:
if not self.worker_started:
self.worker.start()
self.worker_started = True
def create_task(self, payload: TaskCreateRequest) -> TaskRecord:
task_id = uuid.uuid4().hex[:12]
record = TaskRecord(task_id=task_id, file_id=payload.file_id, options=payload)
with self.lock:
self.tasks[task_id] = record
self.queue.put(task_id)
return record
def get_task(self, task_id: str) -> Optional[TaskRecord]:
return self.tasks.get(task_id)
def list_tasks(self) -> List[TaskRecord]:
return sorted(self.tasks.values(), key=lambda task: task.created_at, reverse=True)
def _update_status(self, task: TaskRecord, status: str) -> None:
with self.lock:
task.status = status
task.touch()
def _append_log(self, task: TaskRecord, message: str) -> None:
with self.lock:
task.result.log.append(message)
task.touch()
def _fail_task(self, task: TaskRecord, error: str) -> None:
with self.lock:
task.status = "failed"
task.error = error
task.touch()
def _worker_loop(self) -> None:
while True:
task_id = self.queue.get()
task = self.tasks.get(task_id)
if task is None:
self.queue.task_done()
continue
try:
self._process_task(task)
except Exception as exc: # noqa: BLE001
self._fail_task(task, str(exc))
finally:
self.queue.task_done()
def _process_task(self, task: TaskRecord) -> None:
source_path = get_upload_path(task.file_id)
if not source_path.exists():
raise FileNotFoundError(f"uploaded file not found: {task.file_id}")
self._update_status(task, "processing")
working_source: Path = source_path
if task.options.transcode_mp4:
self._append_log(task, "Transcoding to MP4 with optional clipping and watermark")
mp4_path = transcode_to_mp4(
source=source_path,
task_id=task.task_id,
clip_seconds=task.options.clip_seconds,
watermark_text=task.options.watermark_text,
)
task.result.mp4_url = f"/media/processed/{mp4_path.name}"
working_source = mp4_path
if task.options.generate_cover:
self._append_log(task, "Generating cover image")
cover_path = generate_cover(working_source, task.task_id)
task.result.cover_url = f"/media/covers/{cover_path.name}"
if task.options.generate_hls:
self._append_log(task, "Generating HLS playlist")
playlist = generate_hls(working_source, task.task_id)
task.result.hls_url = f"/media/hls/{task.task_id}/{playlist.name}"
self._append_log(task, "Task completed")
self._update_status(task, "completed")
task_manager = TaskManager()