Initial commit
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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<start>[0-9\.]+)\s+black_end:(?P<end>[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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
python-multipart==0.0.9
|
||||
pydantic==2.8.2
|
||||
Reference in New Issue
Block a user