initialize
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
12
backend/app/config.py
Normal file
12
backend/app/config.py
Normal 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
71
backend/app/main.py
Normal 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
36
backend/app/schemas.py
Normal 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
|
||||
104
backend/app/services/media.py
Normal file
104
backend/app/services/media.py
Normal 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
|
||||
23
backend/app/services/storage.py
Normal file
23
backend/app/services/storage.py
Normal 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
|
||||
124
backend/app/services/tasks.py
Normal file
124
backend/app/services/tasks.py
Normal 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()
|
||||
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
python-multipart==0.0.9
|
||||
Reference in New Issue
Block a user