Files
Video-Cutter/backend/app/main.py
T
2026-06-04 17:42:38 +02:00

476 lines
17 KiB
Python

import io
import json
import logging
import time
import shutil
import uuid
from pathlib import Path
from fastapi import Body, FastAPI, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from . import db, jobs, media, mkv, schemas
from .settings import APP_NAME, JOBS_DIR, PROJECTS_DIR, UPLOADS_DIR, BLACKDETECT_WINDOW
app = FastAPI(title=APP_NAME)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
job_manager = jobs.JobManager()
def _ensure_dirs() -> None:
for directory in (UPLOADS_DIR, JOBS_DIR, PROJECTS_DIR):
directory.mkdir(parents=True, exist_ok=True)
def _require_project() -> dict:
project = db.get_current_project()
if not project:
raise HTTPException(status_code=400, detail="No current project. Create one first.")
return project
def _get_video_or_404(video_id: str) -> dict:
video = db.get_video(video_id)
if not video:
raise HTTPException(status_code=404, detail="Video not found")
return video
def _sanitize_prefix(value: str) -> str:
cleaned = "".join(ch for ch in value if ch.isalnum() or ch in ("-", "_"))
return cleaned or "episode"
def _validate_durations(total_duration: float, intro_seconds: float, outro_seconds: float) -> None:
if intro_seconds + outro_seconds >= total_duration:
raise HTTPException(
status_code=400,
detail="Intro/outro durations exceed or match video length",
)
def _project_dir(project_id: str) -> Path:
path = PROJECTS_DIR / project_id
path.mkdir(parents=True, exist_ok=True)
return path
def _save_project_file(project: dict) -> None:
project_dir = _project_dir(project["id"])
payload = {
"id": project["id"],
"name": project["name"],
"segments_count": project["segments_count"],
"intro_seconds": project["intro_seconds"],
"outro_seconds": project["outro_seconds"],
"reencode_enabled": project["reencode_enabled"],
"ffmpeg_pass1_template": project["ffmpeg_pass1_template"],
"ffmpeg_pass2_template": project["ffmpeg_pass2_template"],
}
(project_dir / "project.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _format_eta(seconds: float | None) -> str | None:
if seconds is None or seconds < 0:
return None
seconds = int(round(seconds))
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours:
return f"{hours:d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def _make_progress_updater(job_id: str, job_label: str, offset: float = 0.0, span: float = 1.0):
started_at = time.monotonic()
stage_started_at = started_at
current_stage = None
def progress(done: float, total: float, message: str | None = None, details: dict | None = None) -> None:
nonlocal stage_started_at, current_stage
now = time.monotonic()
total_fraction = max(0.0, min(1.0, done / float(total))) if total else 0.0
stage = (details or {}).get("stage") or message or job_label
stage_progress = max(0.0, min(1.0, float((details or {}).get("stage_progress", total_fraction))))
if stage != current_stage:
current_stage = stage
stage_started_at = now
elapsed = max(0.001, now - started_at)
stage_elapsed = max(0.001, now - stage_started_at)
total_eta = elapsed * (1.0 - total_fraction) / total_fraction if total_fraction > 0.001 else None
stage_eta = stage_elapsed * (1.0 - stage_progress) / stage_progress if stage_progress > 0.001 else None
payload = {
**(details or {}),
"label": job_label,
"stage": stage,
"stage_progress": stage_progress,
"stage_percent": int(round(stage_progress * 100)),
"total_progress": total_fraction,
"total_percent": int(round(total_fraction * 100)),
"stage_eta_seconds": stage_eta,
"total_eta_seconds": total_eta,
"stage_eta": _format_eta(stage_eta),
"total_eta": _format_eta(total_eta),
"elapsed_seconds": elapsed,
}
full_progress = offset + span * total_fraction
label = message or f"{stage}: {payload['stage_percent']}%"
eta_text = f"ETA {payload['stage_eta']}" if payload["stage_eta"] else "ETA calculating"
job_manager.set_progress(
job_id,
full_progress,
message=f"{label} - {eta_text} - total {int(round(full_progress * 100))}%",
details=payload,
)
return progress
@app.on_event("startup")
def on_startup() -> None:
db.init_db()
_ensure_dirs()
@app.get("/api/health")
def health() -> dict:
return {"status": "ok"}
@app.post("/api/projects", response_model=schemas.ProjectOut)
def create_project(payload: schemas.ProjectCreate) -> dict:
project = db.create_project(
payload.name,
payload.segments_count,
payload.intro_seconds,
payload.outro_seconds,
reencode_enabled=payload.reencode_enabled,
ffmpeg_pass1_template=payload.ffmpeg_pass1_template,
ffmpeg_pass2_template=payload.ffmpeg_pass2_template,
)
_save_project_file(project)
return project
@app.get("/api/projects/current", response_model=schemas.ProjectOut)
def get_current_project() -> dict:
project = db.get_current_project()
if not project:
raise HTTPException(status_code=404, detail="No current project")
return project
@app.put("/api/projects/current", response_model=schemas.ProjectOut)
def update_current_project(payload: schemas.ProjectUpdate) -> dict:
project = db.update_current_project(
name=payload.name,
segments_count=payload.segments_count,
intro_seconds=payload.intro_seconds,
outro_seconds=payload.outro_seconds,
reencode_enabled=payload.reencode_enabled,
ffmpeg_pass1_template=payload.ffmpeg_pass1_template,
ffmpeg_pass2_template=payload.ffmpeg_pass2_template,
)
if not project:
raise HTTPException(status_code=404, detail="No current project")
_save_project_file(project)
return project
@app.post("/api/videos/upload", response_model=schemas.VideoOut)
def upload_video(file: UploadFile = File(...)) -> dict:
project = _require_project()
if not file.filename or not file.filename.lower().endswith(".mkv"):
raise HTTPException(status_code=400, detail="Only .mkv files are supported")
video_id = uuid.uuid4().hex
safe_name = file.filename.replace(" ", "_")
dest_path = UPLOADS_DIR / f"{video_id}_{safe_name}"
with dest_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
duration = media.probe_duration(str(dest_path))
return db.create_video(project["id"], file.filename, str(dest_path), duration)
@app.get("/api/videos/{video_id}", response_model=schemas.VideoOut)
def get_video(video_id: str) -> dict:
return _get_video_or_404(video_id)
@app.get("/api/videos", response_model=list[schemas.VideoOut])
def list_videos() -> list[dict]:
project = _require_project()
return db.list_videos(project["id"])
@app.get("/api/videos/{video_id}/metadata")
def get_video_metadata(video_id: str) -> dict:
video = _get_video_or_404(video_id)
fps = media.probe_framerate(video["file_path"])
return {"fps": fps}
@app.get("/api/videos/{video_id}/markers")
def list_markers(video_id: str) -> dict:
_get_video_or_404(video_id)
return {"markers": db.list_markers(video_id)}
@app.put("/api/videos/{video_id}/markers")
def replace_markers(video_id: str, payload: schemas.MarkersUpdate) -> dict:
_get_video_or_404(video_id)
markers = sorted(payload.markers)
return {"markers": db.replace_markers(video_id, markers, source="manual")}
@app.get("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
def list_segment_edits(video_id: str) -> dict:
_get_video_or_404(video_id)
return {"segments": db.list_segment_edits(video_id)}
@app.put("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
def replace_segment_edits(video_id: str, payload: schemas.SegmentEditsUpdate) -> dict:
_get_video_or_404(video_id)
segments = [
{
"segment_key": item.segment_key,
"start_seconds": item.start_seconds,
"end_seconds": item.end_seconds,
}
for item in payload.segments
]
return {"segments": db.replace_segment_edits(video_id, segments)}
@app.post("/api/videos/{video_id}/autocut", response_model=schemas.JobOut)
def autocut_video(
video_id: str,
payload: schemas.AutoCutRequest | None = Body(default=None),
) -> dict:
video = _get_video_or_404(video_id)
project = _require_project()
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
job = job_manager.create_job("autocut")
job_id = job["id"]
def run_job() -> dict:
window = payload.window_seconds if payload and payload.window_seconds else BLACKDETECT_WINDOW
def progress(done: int, total: int) -> None:
job_manager.set_progress(job_id, done / float(total), message=f"Analisi {done}/{total}")
markers = media.autodetect_cuts(
video["file_path"],
video["duration_seconds"],
project["intro_seconds"],
project["outro_seconds"],
project["segments_count"],
window_seconds=window,
progress_cb=progress,
)
db.replace_markers(video_id, markers, source="auto")
return {"markers": markers}
job_manager.run_in_thread(job_id, run_job)
return job_manager.get_job(job_id)
@app.post("/api/videos/{video_id}/split", response_model=schemas.JobOut)
def split_video(
video_id: str,
payload: schemas.SplitRequest | None = Body(default=None),
) -> dict:
video = _get_video_or_404(video_id)
project = _require_project()
_save_project_file(project)
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
markers = payload.markers if payload and payload.markers else db.list_markers(video_id)
markers = sorted(markers)
if not markers:
raise HTTPException(status_code=400, detail="No markers provided")
output_prefix = _sanitize_prefix(
payload.output_prefix if payload and payload.output_prefix else Path(video["filename"]).stem
)
job = job_manager.create_job("split")
job_id = job["id"]
segment_edits = db.list_segment_edits(video_id)
custom_segments = None
if segment_edits:
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
def run_job() -> dict:
project_dir = _project_dir(project["id"])
output_dir = project_dir / "outputs" / video_id
temp_dir = JOBS_DIR / job_id
progress = _make_progress_updater(job_id, Path(video["filename"]).stem)
outputs = mkv.build_episodes(
video["file_path"],
video["duration_seconds"],
project["intro_seconds"],
project["outro_seconds"],
markers,
output_dir,
temp_dir,
project_dir,
output_prefix=output_prefix,
reencode=project["reencode_enabled"],
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
progress_cb=progress,
log_cb=lambda message: job_manager.log(job_id, message),
custom_segments=custom_segments,
)
db.mark_video_exported(video_id)
return {"outputs": [Path(o).name for o in outputs], "output_dir": str(output_dir)}
job_manager.run_in_thread(job_id, run_job)
return job_manager.get_job(job_id)
@app.post("/api/videos/split-all", response_model=schemas.JobOut)
def split_all_videos(payload: schemas.SplitAllRequest | None = Body(default=None)) -> dict:
project = _require_project()
_save_project_file(project)
videos = db.list_videos(project["id"])
if payload and payload.video_ids:
selected = set(payload.video_ids)
videos = [item for item in videos if item["id"] in selected]
if not videos:
raise HTTPException(status_code=400, detail="No videos available to export")
ready: list[tuple[dict, list[float], list[tuple[float, float]] | None]] = []
skipped: list[dict] = []
for video in videos:
try:
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
except HTTPException as exc:
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": exc.detail})
continue
markers = sorted(db.list_markers(video["id"]))
segment_edits = db.list_segment_edits(video["id"])
custom_segments = None
if segment_edits:
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
if not markers and not custom_segments:
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers or segments"})
continue
ready.append((video, markers, custom_segments))
if not ready:
raise HTTPException(status_code=400, detail="No videos have saved markers ready to export")
job = job_manager.create_job("split_all")
job_id = job["id"]
def run_job() -> dict:
project_dir = _project_dir(project["id"])
all_outputs: list[str] = []
total_videos = len(ready)
for video_index, (video, markers, custom_segments) in enumerate(ready, start=1):
label = f"{video_index}/{total_videos} {Path(video['filename']).stem}"
output_dir = project_dir / "outputs" / video["id"]
temp_dir = JOBS_DIR / job_id / video["id"]
output_prefix = _sanitize_prefix(Path(video["filename"]).stem)
job_manager.log(job_id, f"Starting export for {video['filename']}")
outputs = mkv.build_episodes(
video["file_path"],
video["duration_seconds"],
project["intro_seconds"],
project["outro_seconds"],
markers,
output_dir,
temp_dir,
project_dir,
output_prefix=output_prefix,
reencode=project["reencode_enabled"],
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
progress_cb=_make_progress_updater(
job_id,
label,
offset=(video_index - 1) / float(total_videos),
span=1.0 / float(total_videos),
),
log_cb=lambda message: job_manager.log(job_id, message),
custom_segments=custom_segments,
)
db.mark_video_exported(video["id"])
all_outputs.extend([Path(o).name for o in outputs])
return {"outputs": all_outputs, "skipped": skipped}
job_manager.run_in_thread(job_id, run_job)
return job_manager.get_job(job_id)
@app.delete("/api/videos/{video_id}", status_code=204)
def delete_video(video_id: str) -> None:
video = db.delete_video(video_id)
if not video:
raise HTTPException(status_code=404, detail="Video not found")
file_path = Path(video["file_path"])
if file_path.exists():
file_path.unlink()
@app.get("/api/jobs/{job_id}", response_model=schemas.JobOut)
def get_job(job_id: str) -> dict:
try:
return job_manager.get_job(job_id)
except KeyError:
raise HTTPException(status_code=404, detail="Job not found")
@app.get("/api/videos/{video_id}/frame")
def get_frame(
video_id: str,
ts: float = Query(..., ge=0),
fast: bool = Query(default=False),
w: int | None = Query(default=None, ge=160, le=1920),
):
video = _get_video_or_404(video_id)
if ts > video["duration_seconds"]:
raise HTTPException(status_code=400, detail="Timestamp exceeds duration")
frame_bytes = media.extract_frame_bytes(video["file_path"], ts, fast=fast, scale_width=w)
return StreamingResponse(
io.BytesIO(frame_bytes),
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"},
)
@app.get("/api/videos/{video_id}/outputs/{filename}")
def download_output(video_id: str, filename: str):
project = _require_project()
project_dir = _project_dir(project["id"])
file_path = project_dir / "outputs" / video_id / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="Output file not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="video/x-matroska",
)