From 8fca56e20eaaf19f1e00358309d532297ac9aa69 Mon Sep 17 00:00:00 2001 From: PickleRick Date: Tue, 2 Jun 2026 18:59:31 +0200 Subject: [PATCH] Initial commit --- .gitignore | 107 +++ README.md | 24 + backend/Dockerfile | 20 + backend/app/__init__.py | 0 backend/app/db.py | 375 ++++++++ backend/app/jobs.py | 94 ++ backend/app/main.py | 442 ++++++++++ backend/app/media.py | 213 +++++ backend/app/mkv.py | 491 +++++++++++ backend/app/schemas.py | 87 ++ backend/app/settings.py | 19 + backend/requirements.txt | 4 + docker-compose.yml | 17 + frontend/.dockerignore | 4 + frontend/Dockerfile | 16 + frontend/README.md | 11 + frontend/index.html | 12 + frontend/nginx.conf | 20 + frontend/package.json | 26 + frontend/src/App.tsx | 1620 +++++++++++++++++++++++++++++++++++ frontend/src/api.ts | 164 ++++ frontend/src/index.css | Bin 0 -> 208 bytes frontend/src/main.tsx | 10 + frontend/src/theme.ts | 37 + frontend/src/types.ts | 47 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 16 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 11 + 29 files changed, 3898 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/db.py create mode 100644 backend/app/jobs.py create mode 100644 backend/app/main.py create mode 100644 backend/app/media.py create mode 100644 backend/app/mkv.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/settings.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/theme.ts create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27a4948 --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# ========================================== +# Python (Cache, Ambienti Virtuali e Build) +# ========================================== + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Ambienti virtuali (Locali) +.venv/ +venv/ +ENV/ +env/ + +# Distribuzione / Pacchetti +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Strumenti di Testing / Copertura codice +.toml +.cache +.pytest_cache/ +.htmlcov/ +.tox/ +.noscript/ +.coverage +.coverage.* +nosetests.xml +coverage.xml +htmlcov/ +.hypothesis/ + +# Jupyter Notebooks (File di checkpoint) +.ipynb_checkpoints + +# Mypy e Linters +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.ruff_cache/ + +# ========================================== +# Docker +# ========================================== + +# Esclude i log dei container salvati localmente +*.log +docker/logs/ + +# Se monti dei volumi locali per i dati dei database o caricamenti, escludili +.docker-volumes/ +docker-data/ +data/ + +# ========================================== +# Configurazioni di Sistema & IDE +# ========================================== + +# Credenziali e variabili d'ambiente (CRITICO: mai caricarle su Git) +.env +.env.local +.env.*.local +*.secret +*.pem +*.key + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# IDE (PyCharm, VS Code, ecc.) +.idea/ +.vscode/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.swp +.github/ + +°Folder specifico per il progetto (es. cartella di build, dist, ecc.) +data/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b76bd1 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Video Cutter WebApp + +Backend API per taglio e unione di file MKV usando mkvmerge (stream copy) e FFmpeg per analisi. +Frontend React + MUI con Material Design 3 e supporto dark mode. + +## Avvio rapido (Docker) +- docker-compose up --build + +## Frontend (locale) +- Richiede Node.js 20+ +- cd frontend +- npm install +- npm run dev + +## API principali +- POST /api/projects +- GET /api/projects/current +- POST /api/videos/upload +- POST /api/videos/{video_id}/autocut +- POST /api/videos/{video_id}/split +- GET /api/jobs/{job_id} + +## Note +- I file vengono salvati in ./data (montato in /data nel container). diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..539e3d6 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg mkvtoolnix \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +ENV DATA_DIR=/data +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..d3279c4 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,375 @@ +import sqlite3 +import threading +import uuid +from datetime import datetime + +from .settings import DB_PATH + +_DB_LOCK = threading.Lock() +DEFAULT_FFMPEG_PASS1_TEMPLATE = ( + 'ffmpeg -hwaccel auto -y -ss {start} -t {duration} -i {input} ' + '-map_metadata -1 -map_chapters -1 -an -sn -b:v 2M ' + '-x265-params no-slow-firstpass=1:pass=1:open-gop=0:keyint=60:min-keyint=60:' + 'scenecut=0:vbv-maxrate=4000:vbv-bufsize=8000:stats={stats}:pools=+ ' + '-tune animation -c:v libx265 -f null {null}' +) +DEFAULT_FFMPEG_PASS2_TEMPLATE = ( + 'ffmpeg -hwaccel auto -y -ss {start} -t {duration} -i {input} ' + '-map_metadata -1 -map_chapters -1 -map 0:v:0 -map 0:a? ' + '-disposition:v:0 default -b:v 2M ' + '-x265-params pass=2:open-gop=0:keyint=60:min-keyint=60:scenecut=0:' + 'vbv-maxrate=4000:vbv-bufsize=8000:stats={stats}:pools=+ ' + '-tune animation -b:a 128k -ac:a 2 -c:v libx265 -c:a libopus -c:s subrip {output}' +) + + +def _now() -> str: + return datetime.utcnow().isoformat(timespec="seconds") + "Z" + + +def _row_to_dict(row): + if row is None: + return None + return {key: row[key] for key in row.keys()} + + +def get_conn() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.executescript( + """ + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + segments_count INTEGER NOT NULL, + intro_seconds REAL NOT NULL, + outro_seconds REAL NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS videos ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + filename TEXT NOT NULL, + file_path TEXT NOT NULL, + duration_seconds REAL NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS markers ( + id TEXT PRIMARY KEY, + video_id TEXT NOT NULL, + position_seconds REAL NOT NULL, + source TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS segment_edits ( + id TEXT PRIMARY KEY, + video_id TEXT NOT NULL, + segment_key TEXT NOT NULL, + start_seconds REAL NOT NULL, + end_seconds REAL NOT NULL, + modified_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_markers_video_id ON markers(video_id); + CREATE INDEX IF NOT EXISTS idx_segment_edits_video_id ON segment_edits(video_id); + """ + ) + cur.execute("PRAGMA table_info(projects)") + columns = {row[1] for row in cur.fetchall()} + if "reencode_enabled" not in columns: + cur.execute("ALTER TABLE projects ADD COLUMN reencode_enabled INTEGER NOT NULL DEFAULT 0") + if "ffmpeg_pass1_template" not in columns: + cur.execute("ALTER TABLE projects ADD COLUMN ffmpeg_pass1_template TEXT") + if "ffmpeg_pass2_template" not in columns: + cur.execute("ALTER TABLE projects ADD COLUMN ffmpeg_pass2_template TEXT") + cur.execute("PRAGMA table_info(segment_edits)") + segment_edit_columns = {row[1] for row in cur.fetchall()} + if "modified_at" not in segment_edit_columns: + cur.execute("ALTER TABLE segment_edits ADD COLUMN modified_at TEXT") + cur.execute("UPDATE segment_edits SET modified_at = ?", (_now(),)) + cur.execute( + "UPDATE projects SET ffmpeg_pass1_template = ? WHERE ffmpeg_pass1_template IS NULL", + (DEFAULT_FFMPEG_PASS1_TEMPLATE,), + ) + cur.execute( + "UPDATE projects SET ffmpeg_pass2_template = ? WHERE ffmpeg_pass2_template IS NULL", + (DEFAULT_FFMPEG_PASS2_TEMPLATE,), + ) + conn.commit() + conn.close() + + +def _normalize_project(project: dict | None) -> dict | None: + if not project: + return None + project["reencode_enabled"] = bool(project.get("reencode_enabled", 0)) + project["ffmpeg_pass1_template"] = project.get("ffmpeg_pass1_template") or DEFAULT_FFMPEG_PASS1_TEMPLATE + project["ffmpeg_pass2_template"] = project.get("ffmpeg_pass2_template") or DEFAULT_FFMPEG_PASS2_TEMPLATE + return project + + +def create_project( + name: str, + segments_count: int, + intro_seconds: float, + outro_seconds: float, + reencode_enabled: bool = False, + ffmpeg_pass1_template: str | None = None, + ffmpeg_pass2_template: str | None = None, +) -> dict: + project_id = uuid.uuid4().hex + now = _now() + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute( + """ + INSERT INTO projects ( + id, name, segments_count, intro_seconds, outro_seconds, + reencode_enabled, ffmpeg_pass1_template, ffmpeg_pass2_template, + created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + project_id, + name, + segments_count, + intro_seconds, + outro_seconds, + 1 if reencode_enabled else 0, + ffmpeg_pass1_template or DEFAULT_FFMPEG_PASS1_TEMPLATE, + ffmpeg_pass2_template or DEFAULT_FFMPEG_PASS2_TEMPLATE, + now, + now, + ), + ) + cur.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('current_project_id', ?)", + (project_id,), + ) + conn.commit() + cur.execute("SELECT * FROM projects WHERE id = ?", (project_id,)) + project = _normalize_project(_row_to_dict(cur.fetchone())) + conn.close() + return project + + +def get_project(project_id: str) -> dict | None: + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute("SELECT * FROM projects WHERE id = ?", (project_id,)) + project = _normalize_project(_row_to_dict(cur.fetchone())) + conn.close() + return project + + +def get_current_project() -> dict | None: + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute("SELECT value FROM settings WHERE key = 'current_project_id'") + row = cur.fetchone() + if not row: + conn.close() + return None + project_id = row[0] + cur.execute("SELECT * FROM projects WHERE id = ?", (project_id,)) + project = _normalize_project(_row_to_dict(cur.fetchone())) + conn.close() + return project + + +def update_current_project( + name: str | None = None, + segments_count: int | None = None, + intro_seconds: float | None = None, + outro_seconds: float | None = None, + reencode_enabled: bool | None = None, + ffmpeg_pass1_template: str | None = None, + ffmpeg_pass2_template: str | None = None, +) -> dict | None: + project = get_current_project() + if not project: + return None + fields = [] + params = [] + if name is not None: + fields.append("name = ?") + params.append(name) + if segments_count is not None: + fields.append("segments_count = ?") + params.append(segments_count) + if intro_seconds is not None: + fields.append("intro_seconds = ?") + params.append(intro_seconds) + if outro_seconds is not None: + fields.append("outro_seconds = ?") + params.append(outro_seconds) + if reencode_enabled is not None: + fields.append("reencode_enabled = ?") + params.append(1 if reencode_enabled else 0) + if ffmpeg_pass1_template is not None: + fields.append("ffmpeg_pass1_template = ?") + params.append(ffmpeg_pass1_template or DEFAULT_FFMPEG_PASS1_TEMPLATE) + if ffmpeg_pass2_template is not None: + fields.append("ffmpeg_pass2_template = ?") + params.append(ffmpeg_pass2_template or DEFAULT_FFMPEG_PASS2_TEMPLATE) + if not fields: + return project + fields.append("updated_at = ?") + params.append(_now()) + params.append(project["id"]) + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute(f"UPDATE projects SET {', '.join(fields)} WHERE id = ?", params) + conn.commit() + cur.execute("SELECT * FROM projects WHERE id = ?", (project["id"],)) + updated = _normalize_project(_row_to_dict(cur.fetchone())) + conn.close() + return updated + + +def create_video(project_id: str, filename: str, file_path: str, duration_seconds: float) -> dict: + video_id = uuid.uuid4().hex + now = _now() + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute( + """ + INSERT INTO videos (id, project_id, filename, file_path, duration_seconds, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (video_id, project_id, filename, file_path, duration_seconds, now), + ) + conn.commit() + cur.execute("SELECT * FROM videos WHERE id = ?", (video_id,)) + video = _row_to_dict(cur.fetchone()) + conn.close() + return video + + +def get_video(video_id: str) -> dict | None: + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute("SELECT * FROM videos WHERE id = ?", (video_id,)) + video = _row_to_dict(cur.fetchone()) + conn.close() + return video + + +def list_videos(project_id: str) -> list[dict]: + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute( + "SELECT * FROM videos WHERE project_id = ? ORDER BY created_at DESC", + (project_id,), + ) + rows = cur.fetchall() + conn.close() + return [_row_to_dict(row) for row in rows] + + +def list_markers(video_id: str) -> list[float]: + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute( + "SELECT position_seconds FROM markers WHERE video_id = ? ORDER BY position_seconds", + (video_id,), + ) + rows = cur.fetchall() + conn.close() + return [row[0] for row in rows] + + +def replace_markers(video_id: str, markers: list[float], source: str) -> list[float]: + now = _now() + markers = [float(m) for m in markers] + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute("DELETE FROM markers WHERE video_id = ?", (video_id,)) + for position in markers: + cur.execute( + """ + INSERT INTO markers (id, video_id, position_seconds, source, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (uuid.uuid4().hex, video_id, position, source, now), + ) + conn.commit() + conn.close() + return sorted(markers) + + +def replace_segment_edits(video_id: str, segments: list[dict]) -> list[dict]: + now = _now() + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute("DELETE FROM segment_edits WHERE video_id = ?", (video_id,)) + for segment in segments: + cur.execute( + """ + INSERT INTO segment_edits ( + id, video_id, segment_key, start_seconds, end_seconds, modified_at + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + uuid.uuid4().hex, + video_id, + segment["segment_key"], + float(segment["start_seconds"]), + float(segment["end_seconds"]), + now, + ), + ) + conn.commit() + cur.execute( + """ + SELECT segment_key, start_seconds, end_seconds, modified_at + FROM segment_edits + WHERE video_id = ? + ORDER BY rowid + """, + (video_id,), + ) + rows = cur.fetchall() + conn.close() + return [_row_to_dict(row) for row in rows] + + +def delete_video(video_id: str) -> dict | None: + with _DB_LOCK: + conn = get_conn() + cur = conn.cursor() + cur.execute("SELECT * FROM videos WHERE id = ?", (video_id,)) + video = _row_to_dict(cur.fetchone()) + if not video: + conn.close() + return None + cur.execute("DELETE FROM markers WHERE video_id = ?", (video_id,)) + cur.execute("DELETE FROM segment_edits WHERE video_id = ?", (video_id,)) + cur.execute("DELETE FROM videos WHERE id = ?", (video_id,)) + conn.commit() + conn.close() + return video diff --git a/backend/app/jobs.py b/backend/app/jobs.py new file mode 100644 index 0000000..c69f3ba --- /dev/null +++ b/backend/app/jobs.py @@ -0,0 +1,94 @@ +import threading +import uuid +from datetime import datetime +from collections import deque + + +def _now() -> str: + return datetime.utcnow().isoformat(timespec="seconds") + "Z" + + +class JobManager: + def __init__(self) -> None: + self._jobs: dict[str, dict] = {} + self._lock = threading.Lock() + + def create_job(self, kind: str) -> dict: + job_id = uuid.uuid4().hex + job = { + "id": job_id, + "kind": kind, + "status": "queued", + "progress": 0.0, + "message": None, + "details": {}, + "logs": [], + "result": None, + "created_at": _now(), + "updated_at": _now(), + } + with self._lock: + self._jobs[job_id] = job + return job + + def get_job(self, job_id: str) -> dict: + with self._lock: + job = self._jobs.get(job_id) + if not job: + raise KeyError("Job not found") + return dict(job) + + def update_job( + self, + job_id: str, + status: str | None = None, + progress: float | None = None, + message: str | None = None, + details: dict | None = None, + log: str | None = None, + result: dict | None = None, + ) -> dict: + with self._lock: + job = self._jobs.get(job_id) + if not job: + raise KeyError("Job not found") + if status is not None: + job["status"] = status + if progress is not None: + job["progress"] = max(0.0, min(1.0, float(progress))) + if message is not None: + job["message"] = message + if details is not None: + job["details"] = details + if log is not None: + logs = deque(job.get("logs", []), maxlen=200) + logs.append({"at": _now(), "message": log}) + job["logs"] = list(logs) + if result is not None: + job["result"] = result + job["updated_at"] = _now() + return dict(job) + + def set_progress( + self, + job_id: str, + progress: float, + message: str | None = None, + details: dict | None = None, + ) -> dict: + return self.update_job(job_id, progress=progress, message=message, details=details) + + def log(self, job_id: str, message: str) -> dict: + return self.update_job(job_id, log=message) + + def run_in_thread(self, job_id: str, fn) -> None: + def runner(): + try: + self.update_job(job_id, status="running") + result = fn() + self.update_job(job_id, status="completed", progress=1.0, result=result) + except Exception as exc: # pragma: no cover - used for runtime visibility + self.update_job(job_id, status="failed", message=str(exc)) + + thread = threading.Thread(target=runner, daemon=True) + thread.start() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f099673 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,442 @@ +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 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.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"] + + 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), + ) + return {"outputs": 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]]] = [] + 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"])) + if not markers: + skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers"}) + continue + ready.append((video, markers)) + + 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) 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), + ) + all_outputs.extend(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"}, + ) diff --git a/backend/app/media.py b/backend/app/media.py new file mode 100644 index 0000000..ef5cc11 --- /dev/null +++ b/backend/app/media.py @@ -0,0 +1,213 @@ +import re +import subprocess +from functools import lru_cache +from pathlib import Path +from typing import Callable + +from .settings import ( + BLACKDETECT_DURATION, + BLACKDETECT_PIX_TH, + BLACKDETECT_WINDOW, + FFMPEG_BIN, + FFPROBE_BIN, +) + +_BLACK_RE = re.compile(r"black_start:(?P[0-9\.]+)\s+black_end:(?P[0-9\.]+)") + + +def probe_duration(path: str) -> float: + cmd = [ + FFPROBE_BIN, + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(path), + ] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "ffprobe failed") + return float(result.stdout.strip()) + + +def _parse_rate(value: str) -> float | None: + if not value: + return None + if "/" in value: + num, den = value.split("/", 1) + try: + denominator = float(den) + if denominator == 0: + return None + return float(num) / denominator + except ValueError: + return None + try: + return float(value) + except ValueError: + return None + + +def probe_framerate(path: str) -> float | None: + cmd = [ + FFPROBE_BIN, + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=avg_frame_rate,r_frame_rate", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(path), + ] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + parsed = _parse_rate(line.strip()) + if parsed and parsed > 0: + return parsed + return None + + +def run_blackdetect(path: str, start: float, duration: float) -> list[tuple[float, float]]: + cmd = [ + FFMPEG_BIN, + "-hide_banner", + "-ss", + str(start), + "-t", + str(duration), + "-i", + str(path), + "-vf", + f"blackdetect=d={BLACKDETECT_DURATION}:pix_th={BLACKDETECT_PIX_TH}", + "-an", + "-f", + "null", + "-", + ] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "ffmpeg blackdetect failed") + segments: list[tuple[float, float]] = [] + for line in result.stderr.splitlines(): + match = _BLACK_RE.search(line) + if match: + seg_start = float(match.group("start")) + start + seg_end = float(match.group("end")) + start + segments.append((seg_start, seg_end)) + return segments + + +def autodetect_cuts( + path: str, + total_duration: float, + intro_seconds: float, + outro_seconds: float, + segments_count: int, + window_seconds: float | None = None, + progress_cb: Callable[[int, int], None] | None = None, +) -> list[float]: + if segments_count < 2: + return [] + window = window_seconds or BLACKDETECT_WINDOW + core_duration = max(0.0, total_duration - intro_seconds - outro_seconds) + if core_duration <= 0: + return [] + segment_len = core_duration / float(segments_count) + cut_points: list[float] = [] + total_cuts = segments_count - 1 + + for index in range(1, segments_count): + theoretical = intro_seconds + segment_len * index + scan_start = max(0.0, theoretical - window) + scan_duration = min(window * 2.0, total_duration - scan_start) + + black_segments = run_blackdetect(path, scan_start, scan_duration) + if black_segments: + best = max(black_segments, key=lambda item: item[1] - item[0]) + cut_point = (best[0] + best[1]) / 2.0 + else: + cut_point = theoretical + + cut_points.append(round(cut_point, 3)) + if progress_cb: + progress_cb(index, total_cuts) + + return cut_points + + +def extract_frame_bytes( + path: str, + timestamp: float, + fast: bool = False, + scale_width: int | None = None, +) -> bytes: + file_path = Path(path) + stat = file_path.stat() + timestamp_ms = max(0, round(timestamp * 1000)) + return _extract_frame_bytes_cached( + str(file_path), + stat.st_mtime_ns, + timestamp_ms, + fast, + scale_width, + ) + + +@lru_cache(maxsize=256) +def _extract_frame_bytes_cached( + path: str, + mtime_ns: int, + timestamp_ms: int, + fast: bool, + scale_width: int | None, +) -> bytes: + del mtime_ns + timestamp = timestamp_ms / 1000.0 + cmd = [ + FFMPEG_BIN, + "-hide_banner", + "-loglevel", + "error", + ] + if fast: + cmd += [ + "-ss", + str(timestamp), + "-i", + str(path), + ] + else: + seek_start = max(timestamp - 2.0, 0.0) + cmd += [ + "-ss", + str(seek_start), + "-i", + str(path), + "-ss", + str(timestamp - seek_start), + ] + cmd += [ + "-frames:v", + "1", + ] + if scale_width: + cmd += ["-vf", f"scale={scale_width}:-2"] + cmd += [ + "-an", + "-f", + "image2pipe", + "-vcodec", + "mjpeg", + "-", + ] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode != 0: + error = result.stderr.decode("utf-8", errors="ignore").strip() + raise RuntimeError(error or "ffmpeg frame extraction failed") + return result.stdout diff --git a/backend/app/mkv.py b/backend/app/mkv.py new file mode 100644 index 0000000..59a3e74 --- /dev/null +++ b/backend/app/mkv.py @@ -0,0 +1,491 @@ +import os +import re +import shlex +import subprocess +import time +import logging +from pathlib import Path +from typing import Callable + +from .settings import FFMPEG_BIN, MKVMERGE_BIN + +ProgressCallback = Callable[[float, float, str | None, dict | None], None] +LogCallback = Callable[[str], None] +logger = logging.getLogger(__name__) + +def _logical_cpus() -> int: + return max(1, os.cpu_count() or 1) + + +def _format_eta(seconds: float | None) -> str | None: + if seconds is None or not isinstance(seconds, (int, float)) 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 _run_mkvmerge( + cmd: list[str], + label: str, + progress_cb: ProgressCallback | None = None, + progress_start: float = 0.0, + progress_weight: float = 1.0, + log_cb: LogCallback | None = None, +) -> None: + run_cmd = [cmd[0], "--gui-mode", *cmd[1:]] + logger.info("Starting mkvmerge process: %s", label) + if log_cb: + log_cb(f"Starting mkvmerge: {label}") + process = subprocess.Popen( + run_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + output: list[str] = [] + pattern = re.compile(r"#GUI#progress\s+(\d+)") + if process.stdout: + for line in process.stdout: + output.append(line) + match = pattern.search(line) + if match and progress_cb: + stage_progress = max(0.0, min(1.0, int(match.group(1)) / 100.0)) + progress_cb( + progress_start + progress_weight * stage_progress, + 1.0, + f"{label}: {int(round(stage_progress * 100))}%", + {"stage": label, "stage_progress": stage_progress}, + ) + if process.wait() != 0: + logger.error("mkvmerge failed: %s", label) + raise RuntimeError("".join(output).strip() or "mkvmerge failed") + + +def _run_ffmpeg_template( + template: str, + input_path: str, + output_path: Path, + start: float, + duration: float, + stats_path: Path, + label: str, + progress_cb: ProgressCallback | None = None, + progress_start: float = 0.0, + progress_weight: float = 1.0, + log_cb: LogCallback | None = None, +) -> None: + threads = _logical_cpus() + placeholders = { + "ffmpeg": FFMPEG_BIN, + "input": str(Path(input_path)).replace("\\", "/"), + "output": str(output_path).replace("\\", "/"), + "start": f"{max(0.0, start):.6f}", + "duration": f"{max(0.0, duration):.6f}", + "end": f"{max(0.0, start + duration):.6f}", + "stats": str(stats_path).replace("\\", "/"), + "null": "NUL" if os.name == "nt" else "/dev/null", + "threads": str(threads), + "x265_threads": str(threads), + } + command = template.format(**placeholders).strip() + if not command: + return + args = shlex.split(command, posix=True) + if args and args[0].lower() == "ffmpeg": + args[0] = FFMPEG_BIN + if "-threads" not in args: + args = [args[0], "-threads", str(threads), *args[1:]] + if "-x265-params" in args: + param_index = args.index("-x265-params") + 1 + if param_index < len(args) and "pools=" not in args[param_index]: + args[param_index] = f"{args[param_index]}:pools=+" + if "-progress" not in args: + args = [args[0], "-hide_banner", "-nostats", "-progress", "pipe:1", *args[1:]] + if log_cb: + log_cb(f"Starting ffmpeg: {label} ({format_timestamp(start)} + {duration:.3f}s)") + logger.info("Starting ffmpeg process: %s (%s + %.3fs)", label, format_timestamp(start), duration) + env = os.environ.copy() + env.setdefault("OMP_NUM_THREADS", str(threads)) + env.setdefault("X265_NUM_THREADS", str(threads)) + process = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + output: list[str] = [] + last_stage_progress = 0.0 + if process.stdout: + for line in process.stdout: + output.append(line) + if "=" not in line: + continue + key, value = line.strip().split("=", 1) + elapsed = None + if key == "out_time_ms": + try: + elapsed = float(value) / 1_000_000.0 + except ValueError: + elapsed = None + elif key == "out_time_us": + try: + elapsed = float(value) / 1_000_000.0 + except ValueError: + elapsed = None + elif key == "out_time": + parts = value.split(":") + if len(parts) == 3: + try: + elapsed = int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) + except ValueError: + elapsed = None + if elapsed is not None and duration > 0 and progress_cb: + stage_progress = max(last_stage_progress, min(1.0, elapsed / duration)) + last_stage_progress = stage_progress + progress_cb( + progress_start + progress_weight * stage_progress, + 1.0, + f"{label}: {int(round(stage_progress * 100))}%", + {"stage": label, "stage_progress": stage_progress}, + ) + if process.wait() != 0: + logger.error("ffmpeg failed: %s", label) + raise RuntimeError("".join(output[-80:]).strip() or "ffmpeg reencode failed") + + +def format_timestamp(seconds: float) -> str: + seconds = max(0.0, float(seconds)) + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = seconds % 60.0 + return f"{hours:02d}:{minutes:02d}:{secs:06.3f}" + + +def split_range( + input_path: str, + start: float, + end: float, + output_path: Path, + label: str, + progress_cb: ProgressCallback | None = None, + progress_start: float = 0.0, + progress_weight: float = 1.0, + log_cb: LogCallback | None = None, +) -> Path: + output_path = output_path.with_suffix(".mkv") + output_path.parent.mkdir(parents=True, exist_ok=True) + split_arg = f"parts:{format_timestamp(start)}-{format_timestamp(end)}" + cmd = [MKVMERGE_BIN, "-o", str(output_path), "--split", split_arg, str(input_path)] + _run_mkvmerge(cmd, label, progress_cb, progress_start, progress_weight, log_cb) + + if output_path.exists(): + return output_path + + candidates = sorted(output_path.parent.glob(f"{output_path.stem}-*.mkv")) + if not candidates: + raise RuntimeError("mkvmerge did not produce output files") + + final_path = output_path + if final_path.exists(): + final_path.unlink() + candidates[0].replace(final_path) + for extra in candidates[1:]: + extra.unlink() + + return final_path + + +def encode_range( + input_path: str, + start: float, + end: float, + output_path: Path, + project_dir: Path, + pass1_template: str | None, + pass2_template: str, + label: str, + progress_cb: ProgressCallback | None = None, + progress_start: float = 0.0, + progress_weight: float = 1.0, + log_cb: LogCallback | None = None, +) -> Path: + output_path = output_path.with_suffix(".mkv") + output_path.parent.mkdir(parents=True, exist_ok=True) + duration = max(0.0, end - start) + if duration <= 0: + raise RuntimeError("Cannot encode an empty segment") + + stats_dir = project_dir / "ffmpeg-stats" + stats_dir.mkdir(parents=True, exist_ok=True) + stats_path = stats_dir / output_path.stem + + pass1_weight = progress_weight * 0.45 if pass1_template else 0.0 + pass2_weight = progress_weight - pass1_weight + + if pass1_template: + _run_ffmpeg_template( + pass1_template, + input_path, + output_path, + start, + duration, + stats_path, + f"{label} pass 1", + progress_cb, + progress_start, + pass1_weight, + log_cb, + ) + _run_ffmpeg_template( + pass2_template, + input_path, + output_path, + start, + duration, + stats_path, + f"{label} pass 2 + audio", + progress_cb, + progress_start + pass1_weight, + pass2_weight, + log_cb, + ) + + if not output_path.exists(): + raise RuntimeError("ffmpeg did not produce output file") + return output_path + + +def cut_range( + input_path: str, + start: float, + end: float, + output_path: Path, + project_dir: Path, + reencode: bool, + pass1_template: str | None, + pass2_template: str | None, + label: str, + progress_cb: ProgressCallback | None = None, + progress_start: float = 0.0, + progress_weight: float = 1.0, + log_cb: LogCallback | None = None, +) -> Path: + if not reencode: + return split_range( + input_path, + start, + end, + output_path, + label, + progress_cb, + progress_start, + progress_weight, + log_cb, + ) + if not pass2_template: + raise RuntimeError("Reencode is enabled but ffmpeg pass 2 template is empty") + return encode_range( + input_path, + start, + end, + output_path, + project_dir, + pass1_template, + pass2_template, + label, + progress_cb, + progress_start, + progress_weight, + log_cb, + ) + + +def append_segments( + intro_path: Path | None, + segment_path: Path, + outro_path: Path | None, + output_path: Path, + label: str, + progress_cb: ProgressCallback | None = None, + progress_start: float = 0.0, + progress_weight: float = 1.0, + log_cb: LogCallback | None = None, +) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + args: list[str] = [] + if intro_path: + args.append(str(intro_path)) + args.append("+") + args.append(str(segment_path)) + if outro_path: + args.append("+") + args.append(str(outro_path)) + + cmd = [MKVMERGE_BIN, "-o", str(output_path)] + args + _run_mkvmerge(cmd, label, progress_cb, progress_start, progress_weight, log_cb) + + +def build_episodes( + video_path: str, + total_duration: float, + intro_seconds: float, + outro_seconds: float, + cut_points: list[float], + output_dir: Path, + temp_dir: Path, + project_dir: Path, + output_prefix: str = "episode", + reencode: bool = False, + ffmpeg_pass1_template: str | None = None, + ffmpeg_pass2_template: str | None = None, + progress_cb: ProgressCallback | None = None, + log_cb: LogCallback | None = None, +) -> list[str]: + output_dir.mkdir(parents=True, exist_ok=True) + temp_dir.mkdir(parents=True, exist_ok=True) + + min_segment = 0.001 + + core_end = max(intro_seconds, total_duration - outro_seconds) + boundaries = [p for p in sorted(cut_points) if intro_seconds < p < core_end] + boundaries.append(core_end) + + outputs: list[str] = [] + prev = intro_seconds + safe_boundaries: list[float] = [] + + for end in boundaries: + if end - prev <= min_segment: + continue + safe_boundaries.append(end) + prev = end + + if not safe_boundaries: + raise RuntimeError("No valid segments after filtering short ranges") + + prev = intro_seconds + total_segments = len(safe_boundaries) + core_ranges: list[tuple[int, float, float]] = [] + for index, end in enumerate(safe_boundaries, start=1): + core_ranges.append((index, prev, end)) + prev = end + + intro_work = intro_seconds if intro_seconds > min_segment else 0.0 + outro_start = max(0.0, total_duration - outro_seconds) + outro_work = total_duration - outro_start if outro_seconds > min_segment else 0.0 + core_work = sum(max(0.0, end - start) for _, start, end in core_ranges) + mux_work = max(1.0, total_segments * 2.0) + total_work = max(1.0, intro_work + outro_work + core_work + mux_work) + completed_work = 0.0 + + def stage_progress( + base: float, + weight: float, + message_prefix: str, + ) -> ProgressCallback: + def callback(done: float, total: float, message: str | None, details: dict | None) -> None: + stage_fraction = done / total if total else 0.0 + payload = dict(details or {}) + payload.setdefault("stage", message_prefix) + payload["stage_progress"] = max(0.0, min(1.0, stage_fraction)) + if progress_cb: + progress_cb( + base + weight * payload["stage_progress"], + total_work, + message, + payload, + ) + + return callback + + intro_path = None + if intro_work > min_segment: + label = "Extracting intro" + intro_path = cut_range( + video_path, + 0.0, + intro_seconds, + temp_dir / "intro.mkv", + project_dir, + reencode, + ffmpeg_pass1_template, + ffmpeg_pass2_template, + label, + stage_progress(completed_work, intro_work, label), + 0.0, + 1.0, + log_cb, + ) + completed_work += intro_work + + outro_path = None + if outro_work > min_segment: + label = "Extracting outro" + outro_path = cut_range( + video_path, + outro_start, + total_duration, + temp_dir / "outro.mkv", + project_dir, + reencode, + ffmpeg_pass1_template, + ffmpeg_pass2_template, + label, + stage_progress(completed_work, outro_work, label), + 0.0, + 1.0, + log_cb, + ) + completed_work += outro_work + + for index, start, end in core_ranges: + segment_duration = max(min_segment, end - start) + label = f"Segment {index}/{total_segments}" + segment_path = cut_range( + video_path, + start, + end, + temp_dir / f"segment_{index:02d}.mkv", + project_dir, + reencode, + ffmpeg_pass1_template, + ffmpeg_pass2_template, + label, + stage_progress(completed_work, segment_duration, label), + 0.0, + 1.0, + log_cb, + ) + completed_work += segment_duration + episode_path = output_dir / f"{output_prefix}_{index:02d}.mkv" + mux_label = f"Muxing episode {index}/{total_segments}" + append_segments( + intro_path, + segment_path, + outro_path, + episode_path, + mux_label, + stage_progress(completed_work, 2.0, mux_label), + 0.0, + 1.0, + log_cb, + ) + outputs.append(str(episode_path)) + completed_work += 2.0 + if progress_cb: + progress_cb( + completed_work, + total_work, + f"Exported segment {index}/{total_segments}", + {"stage": f"Segment {index}/{total_segments} complete", "stage_progress": 1.0}, + ) + + return outputs diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..07361db --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,87 @@ +from pydantic import BaseModel, Field + + +class ProjectCreate(BaseModel): + name: str = Field(default="Default") + segments_count: int = Field(ge=1) + intro_seconds: float = Field(ge=0) + outro_seconds: float = Field(ge=0) + reencode_enabled: bool = False + ffmpeg_pass1_template: str | None = None + ffmpeg_pass2_template: str | None = None + + +class ProjectUpdate(BaseModel): + name: str | None = None + segments_count: int | None = Field(default=None, ge=1) + intro_seconds: float | None = Field(default=None, ge=0) + outro_seconds: float | None = Field(default=None, ge=0) + reencode_enabled: bool | None = None + ffmpeg_pass1_template: str | None = None + ffmpeg_pass2_template: str | None = None + + +class ProjectOut(BaseModel): + id: str + name: str + segments_count: int + intro_seconds: float + outro_seconds: float + reencode_enabled: bool + ffmpeg_pass1_template: str | None = None + ffmpeg_pass2_template: str | None = None + created_at: str + updated_at: str + + +class VideoOut(BaseModel): + id: str + project_id: str + filename: str + file_path: str + duration_seconds: float + created_at: str + + +class MarkersUpdate(BaseModel): + markers: list[float] + + +class SegmentEdit(BaseModel): + segment_key: str + start_seconds: float = Field(ge=0) + end_seconds: float = Field(ge=0) + + +class SegmentEditsUpdate(BaseModel): + segments: list[SegmentEdit] + + +class SegmentEditOut(SegmentEdit): + modified_at: str + + +class AutoCutRequest(BaseModel): + window_seconds: float | None = None + + +class SplitRequest(BaseModel): + markers: list[float] | None = None + output_prefix: str | None = None + + +class SplitAllRequest(BaseModel): + video_ids: list[str] | None = None + + +class JobOut(BaseModel): + id: str + kind: str + status: str + progress: float + message: str | None = None + details: dict | None = None + logs: list[dict] = Field(default_factory=list) + result: dict | None = None + created_at: str + updated_at: str diff --git a/backend/app/settings.py b/backend/app/settings.py new file mode 100644 index 0000000..ee1117f --- /dev/null +++ b/backend/app/settings.py @@ -0,0 +1,19 @@ +from pathlib import Path +import os + +APP_NAME = "Video Cutter API" + +DATA_DIR = Path(os.getenv("DATA_DIR", "./data")).resolve() +DB_PATH = Path(os.getenv("DB_PATH", str(DATA_DIR / "app.db"))).resolve() +UPLOADS_DIR = Path(os.getenv("UPLOADS_DIR", str(DATA_DIR / "uploads"))).resolve() +OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", str(DATA_DIR / "output"))).resolve() +JOBS_DIR = Path(os.getenv("JOBS_DIR", str(DATA_DIR / "jobs"))).resolve() +PROJECTS_DIR = Path(os.getenv("PROJECTS_DIR", str(DATA_DIR / "projects"))).resolve() + +FFMPEG_BIN = os.getenv("FFMPEG_BIN", "ffmpeg") +FFPROBE_BIN = os.getenv("FFPROBE_BIN", "ffprobe") +MKVMERGE_BIN = os.getenv("MKVMERGE_BIN", "mkvmerge") + +BLACKDETECT_WINDOW = float(os.getenv("BLACKDETECT_WINDOW", "15")) +BLACKDETECT_DURATION = float(os.getenv("BLACKDETECT_DURATION", "0.2")) +BLACKDETECT_PIX_TH = float(os.getenv("BLACKDETECT_PIX_TH", "0.1")) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..60d536e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +python-multipart==0.0.9 +pydantic==2.8.2 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ca342f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + backend: + build: + context: ./backend + ports: + - "8000:8000" + volumes: + - ./data:/data + environment: + - DATA_DIR=/data + frontend: + build: + context: ./frontend + ports: + - "5173:80" + depends_on: + - backend diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..7fa71c6 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.dist +dist +.vscode diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..88ff854 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..c15798d --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,11 @@ +# Frontend + +React + MUI client for the Video Cutter backend. + +## Local dev +- Install Node.js 20+ +- npm install +- npm run dev + +## API +- Uses /api proxy during dev diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b770389 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Video Cutter Studio + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..5b89cf2 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + client_max_body_size 2g; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..85daf5b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "video-cutter-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^5.15.21", + "@mui/material": "^5.15.21", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.2" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..7549c66 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,1620 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + AppBar, + Box, + Button, + Chip, + Container, + CssBaseline, + Drawer, + FormControlLabel, + Grid, + IconButton, + LinearProgress, + Paper, + Slider, + Stack, + Step, + StepContent, + StepLabel, + Stepper, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Toolbar, + Typography, +} from "@mui/material"; +import { ThemeProvider } from "@mui/material/styles"; +import { + Brightness4, + Brightness7, + CallSplit, + ContentCopy, + Delete, + MyLocation, + Settings, + SkipNext, + SkipPrevious, + UploadFile, +} from "@mui/icons-material"; + +import { + autoCutVideo, + createProject, + deleteVideo, + getCurrentProject, + getJob, + getVideoMetadata, + listMarkers, + listVideos, + replaceMarkers, + replaceSegmentEdits, + splitAllVideos, + splitVideo, + updateProject, + uploadVideos, +} from "./api"; +import { Job, Project, Video } from "./types"; +import { buildTheme, ColorMode } from "./theme"; + +const formatTime = (value: number) => { + const total = Math.max(0, value); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const padded = seconds.toFixed(3).padStart(6, "0"); + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${padded}`; +}; + +const parseTimecode = (value: string) => { + const trimmed = value.trim(); + const match = /^(\d+):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?$/.exec(trimmed); + if (!match) { + return null; + } + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const millisRaw = match[4] ?? "0"; + const millis = Number(millisRaw.padEnd(3, "0")); + if (Number.isNaN(hours) || Number.isNaN(minutes) || Number.isNaN(seconds) || Number.isNaN(millis)) { + return null; + } + return hours * 3600 + minutes * 60 + seconds + millis / 1000; +}; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +const DEFAULT_PASS1_TEMPLATE = + "ffmpeg -hwaccel auto -y -ss {start} -t {duration} -i {input} -map_metadata -1 -map_chapters -1 -an -sn -b:v 2M -x265-params no-slow-firstpass=1:pass=1:open-gop=0:keyint=60:min-keyint=60:scenecut=0:vbv-maxrate=4000:vbv-bufsize=8000:stats={stats}:pools=+ -tune animation -c:v libx265 -f null {null}"; +const DEFAULT_PASS2_TEMPLATE = + "ffmpeg -hwaccel auto -y -ss {start} -t {duration} -i {input} -map_metadata -1 -map_chapters -1 -map 0:v:0 -map 0:a? -disposition:v:0 default -b:v 2M -x265-params pass=2:open-gop=0:keyint=60:min-keyint=60:scenecut=0:vbv-maxrate=4000:vbv-bufsize=8000:stats={stats}:pools=+ -tune animation -b:a 128k -ac:a 2 -c:v libx265 -c:a libopus -c:s subrip {output}"; + +export default function App() { + const [mode, setMode] = useState("dark"); + const theme = useMemo(() => buildTheme(mode), [mode]); + + const [activeStep, setActiveStep] = useState(0); + const [project, setProject] = useState(null); + const [projectDrawerOpen, setProjectDrawerOpen] = useState(false); + const [projectForm, setProjectForm] = useState({ + name: "Season", + segments: 4, + intro: 90, + outro: 90, + reencodeEnabled: false, + ffmpegPass1Template: DEFAULT_PASS1_TEMPLATE, + ffmpegPass2Template: DEFAULT_PASS2_TEMPLATE, + }); + + const [video, setVideo] = useState