Initial commit

This commit is contained in:
2026-06-02 18:59:31 +02:00
commit 8fca56e20e
29 changed files with 3898 additions and 0 deletions
+20
View File
@@ -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"]
View File
+375
View File
@@ -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
+94
View File
@@ -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()
+442
View File
@@ -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"},
)
+213
View File
@@ -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
+491
View File
@@ -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
+87
View File
@@ -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
+19
View File
@@ -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"))
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
python-multipart==0.0.9
pydantic==2.8.2