Initial commit
This commit is contained in:
+107
@@ -0,0 +1,107 @@
|
|||||||
|
# ==========================================
|
||||||
|
# Python (Cache, Ambienti Virtuali e Build)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Ambienti virtuali (Locali)
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Distribuzione / Pacchetti
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Strumenti di Testing / Copertura codice
|
||||||
|
.toml
|
||||||
|
.cache
|
||||||
|
.pytest_cache/
|
||||||
|
.htmlcov/
|
||||||
|
.tox/
|
||||||
|
.noscript/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Jupyter Notebooks (File di checkpoint)
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# Mypy e Linters
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
.pyre/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Docker
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# Esclude i log dei container salvati localmente
|
||||||
|
*.log
|
||||||
|
docker/logs/
|
||||||
|
|
||||||
|
# Se monti dei volumi locali per i dati dei database o caricamenti, escludili
|
||||||
|
.docker-volumes/
|
||||||
|
docker-data/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Configurazioni di Sistema & IDE
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# Credenziali e variabili d'ambiente (CRITICO: mai caricarle su Git)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.secret
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# IDE (PyCharm, VS Code, ecc.)
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.swp
|
||||||
|
.github/
|
||||||
|
|
||||||
|
°Folder specifico per il progetto (es. cartella di build, dist, ecc.)
|
||||||
|
data/
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Video Cutter WebApp
|
||||||
|
|
||||||
|
Backend API per taglio e unione di file MKV usando mkvmerge (stream copy) e FFmpeg per analisi.
|
||||||
|
Frontend React + MUI con Material Design 3 e supporto dark mode.
|
||||||
|
|
||||||
|
## Avvio rapido (Docker)
|
||||||
|
- docker-compose up --build
|
||||||
|
|
||||||
|
## Frontend (locale)
|
||||||
|
- Richiede Node.js 20+
|
||||||
|
- cd frontend
|
||||||
|
- npm install
|
||||||
|
- npm run dev
|
||||||
|
|
||||||
|
## API principali
|
||||||
|
- POST /api/projects
|
||||||
|
- GET /api/projects/current
|
||||||
|
- POST /api/videos/upload
|
||||||
|
- POST /api/videos/{video_id}/autocut
|
||||||
|
- POST /api/videos/{video_id}/split
|
||||||
|
- GET /api/jobs/{job_id}
|
||||||
|
|
||||||
|
## Note
|
||||||
|
- I file vengono salvati in ./data (montato in /data nel container).
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- DATA_DIR=/data
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
ports:
|
||||||
|
- "5173:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.dist
|
||||||
|
dist
|
||||||
|
.vscode
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Frontend
|
||||||
|
|
||||||
|
React + MUI client for the Video Cutter backend.
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
- Install Node.js 20+
|
||||||
|
- npm install
|
||||||
|
- npm run dev
|
||||||
|
|
||||||
|
## API
|
||||||
|
- Uses /api proxy during dev
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Video Cutter Studio</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 2g;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "video-cutter-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.4",
|
||||||
|
"@emotion/styled": "^11.11.5",
|
||||||
|
"@mui/icons-material": "^5.15.21",
|
||||||
|
"@mui/material": "^5.15.21",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
|||||||
|
import { Job, Project, SegmentEdit, Video } from "./types";
|
||||||
|
|
||||||
|
const rawBase = import.meta.env.VITE_API_BASE ?? "";
|
||||||
|
const apiBase = rawBase.endsWith("/") ? rawBase.slice(0, -1) : rawBase;
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
if (!(options.body instanceof FormData)) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${apiBase}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Request failed (${response.status})`;
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data?.detail) {
|
||||||
|
message = data.detail;
|
||||||
|
} else if (data?.message) {
|
||||||
|
message = data.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
throw new ApiError(response.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentProject(): Promise<Project> {
|
||||||
|
return request("/api/projects/current");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProject(payload: {
|
||||||
|
name: string;
|
||||||
|
segments_count: number;
|
||||||
|
intro_seconds: number;
|
||||||
|
outro_seconds: number;
|
||||||
|
reencode_enabled?: boolean;
|
||||||
|
ffmpeg_pass1_template?: string | null;
|
||||||
|
ffmpeg_pass2_template?: string | null;
|
||||||
|
}): Promise<Project> {
|
||||||
|
return request("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProject(payload: {
|
||||||
|
name?: string;
|
||||||
|
segments_count?: number;
|
||||||
|
intro_seconds?: number;
|
||||||
|
outro_seconds?: number;
|
||||||
|
reencode_enabled?: boolean;
|
||||||
|
ffmpeg_pass1_template?: string | null;
|
||||||
|
ffmpeg_pass2_template?: string | null;
|
||||||
|
}): Promise<Project> {
|
||||||
|
return request("/api/projects/current", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadVideo(file: File): Promise<Video> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return request("/api/videos/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listVideos(): Promise<Video[]> {
|
||||||
|
return request("/api/videos");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteVideo(videoId: string): Promise<void> {
|
||||||
|
await request(`/api/videos/${videoId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVideoMetadata(videoId: string): Promise<{ fps: number | null }> {
|
||||||
|
return request(`/api/videos/${videoId}/metadata`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMarkers(videoId: string): Promise<{ markers: number[] }> {
|
||||||
|
return request(`/api/videos/${videoId}/markers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceMarkers(
|
||||||
|
videoId: string,
|
||||||
|
markers: number[]
|
||||||
|
): Promise<{ markers: number[] }> {
|
||||||
|
return request(`/api/videos/${videoId}/markers`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ markers }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadVideos(files: File[]): Promise<Video[]> {
|
||||||
|
const uploaded: Video[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
uploaded.push(await uploadVideo(file));
|
||||||
|
}
|
||||||
|
return uploaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceSegmentEdits(
|
||||||
|
videoId: string,
|
||||||
|
segments: { segment_key: string; start_seconds: number; end_seconds: number }[]
|
||||||
|
): Promise<{ segments: SegmentEdit[] }> {
|
||||||
|
return request(`/api/videos/${videoId}/segment-edits`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ segments }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoCutVideo(videoId: string, windowSeconds?: number): Promise<Job> {
|
||||||
|
return request(`/api/videos/${videoId}/autocut`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ window_seconds: windowSeconds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function splitVideo(
|
||||||
|
videoId: string,
|
||||||
|
markers: number[],
|
||||||
|
outputPrefix?: string
|
||||||
|
): Promise<Job> {
|
||||||
|
return request(`/api/videos/${videoId}/split`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ markers, output_prefix: outputPrefix }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function splitAllVideos(videoIds?: string[]): Promise<Job> {
|
||||||
|
return request("/api/videos/split-all", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ video_ids: videoIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJob(jobId: string): Promise<Job> {
|
||||||
|
return request(`/api/jobs/${jobId}`);
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
export type ColorMode = "light" | "dark";
|
||||||
|
|
||||||
|
export function buildTheme(mode: ColorMode) {
|
||||||
|
return createTheme({
|
||||||
|
palette: {
|
||||||
|
mode,
|
||||||
|
primary: {
|
||||||
|
main: "#1976d2",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: "#dc004e",
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: mode === "dark" ? "#121212" : "#f5f5f5",
|
||||||
|
paper: mode === "dark" ? "#1e1e1e" : "#ffffff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
boxShadow:
|
||||||
|
mode === "dark"
|
||||||
|
? "0px 2px 4px rgba(0, 0, 0, 0.4)"
|
||||||
|
: "0px 2px 4px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export type Project = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
segments_count: number;
|
||||||
|
intro_seconds: number;
|
||||||
|
outro_seconds: number;
|
||||||
|
reencode_enabled: boolean;
|
||||||
|
ffmpeg_pass1_template: string | null;
|
||||||
|
ffmpeg_pass2_template: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Video = {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
filename: string;
|
||||||
|
file_path: string;
|
||||||
|
duration_seconds: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Job = {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
status: "queued" | "running" | "completed" | "failed";
|
||||||
|
progress: number;
|
||||||
|
message: string | null;
|
||||||
|
details: {
|
||||||
|
stage?: string;
|
||||||
|
stage_percent?: number;
|
||||||
|
stage_eta?: string | null;
|
||||||
|
total_eta?: string | null;
|
||||||
|
label?: string;
|
||||||
|
} | null;
|
||||||
|
logs: { at: string; message: string }[];
|
||||||
|
result: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SegmentEdit = {
|
||||||
|
segment_key: string;
|
||||||
|
start_seconds: number;
|
||||||
|
end_seconds: number;
|
||||||
|
modified_at: string;
|
||||||
|
};
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user