Compare commits
12 Commits
v0.9.0
..
fbbfa6fdce
| Author | SHA1 | Date | |
|---|---|---|---|
| fbbfa6fdce | |||
| 56920a5247 | |||
| 08c1dd8b33 | |||
| 2b63ad1857 | |||
| 72f6a4e8f5 | |||
| 799a2ab353 | |||
| 066f4431ba | |||
| 97f9ef7073 | |||
| 592e40c1a6 | |||
| 73dd7a1569 | |||
| 7abf0b4d4c | |||
| 9e5bd4a8ec |
@@ -0,0 +1,55 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def build_annotation_json_path(folder: str) -> str:
|
||||||
|
return os.path.join(folder, "dataset.json")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_clip_annotation(folder: str, clip_path: str) -> None:
|
||||||
|
"""Remove the entry for *clip_path* from <folder>/dataset.json if present."""
|
||||||
|
json_path = build_annotation_json_path(folder)
|
||||||
|
if not os.path.exists(json_path):
|
||||||
|
return
|
||||||
|
abs_path = os.path.abspath(clip_path)
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
try:
|
||||||
|
entries = json.load(f)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return
|
||||||
|
entries = [e for e in entries if e.get("path") != abs_path]
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(entries, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_clip_annotation(folder: str, clip_path: str, label: str) -> None:
|
||||||
|
"""Insert or update one entry in <folder>/dataset.json.
|
||||||
|
|
||||||
|
Each entry stores a path relative to *folder* and the sound label.
|
||||||
|
Matches on ``path``; if an entry for the same clip already exists it is
|
||||||
|
replaced (overwrite-export case). Nothing is written when *label* is
|
||||||
|
empty.
|
||||||
|
"""
|
||||||
|
if not label.strip():
|
||||||
|
return
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
json_path = build_annotation_json_path(folder)
|
||||||
|
entries: list[dict] = []
|
||||||
|
if os.path.exists(json_path):
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
try:
|
||||||
|
entries = json.load(f)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
entries = []
|
||||||
|
abs_path = os.path.abspath(clip_path)
|
||||||
|
entry: dict = {"path": abs_path, "label": label}
|
||||||
|
for i, e in enumerate(entries):
|
||||||
|
if e.get("path") == abs_path:
|
||||||
|
entries[i] = entry
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
entries.append(entry)
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(entries, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import _log
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedDB:
|
||||||
|
_SCHEMA_VERSION = 3 # bump when schema changes
|
||||||
|
|
||||||
|
def __init__(self, db_path: str | None = None):
|
||||||
|
if db_path is None:
|
||||||
|
db_path = str(Path.home() / ".8cut.db")
|
||||||
|
self._path = db_path
|
||||||
|
try:
|
||||||
|
self._con = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
self._migrate()
|
||||||
|
self._enabled = True
|
||||||
|
_log(f"DB opened: {db_path}")
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"DB unavailable: {e}")
|
||||||
|
self._con = None
|
||||||
|
self._enabled = False
|
||||||
|
|
||||||
|
def _migrate(self) -> None:
|
||||||
|
"""Create table if missing, then add any new columns for old DBs."""
|
||||||
|
cols = {
|
||||||
|
row[1]
|
||||||
|
for row in self._con.execute("PRAGMA table_info(processed)").fetchall()
|
||||||
|
}
|
||||||
|
if not cols:
|
||||||
|
# Fresh DB — create from scratch
|
||||||
|
self._con.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS processed ("
|
||||||
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||||
|
" filename TEXT NOT NULL,"
|
||||||
|
" start_time REAL NOT NULL,"
|
||||||
|
" output_path TEXT NOT NULL,"
|
||||||
|
" label TEXT NOT NULL DEFAULT '',"
|
||||||
|
" category TEXT NOT NULL DEFAULT '',"
|
||||||
|
" short_side INTEGER DEFAULT 512,"
|
||||||
|
" portrait_ratio TEXT NOT NULL DEFAULT '',"
|
||||||
|
" crop_center REAL NOT NULL DEFAULT 0.5,"
|
||||||
|
" format TEXT NOT NULL DEFAULT 'MP4',"
|
||||||
|
" clip_count INTEGER NOT NULL DEFAULT 3,"
|
||||||
|
" spread REAL NOT NULL DEFAULT 3.0,"
|
||||||
|
" profile TEXT NOT NULL DEFAULT 'default',"
|
||||||
|
" processed_at TEXT NOT NULL"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Add missing columns to legacy tables
|
||||||
|
new_cols = {
|
||||||
|
"label": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"category": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"short_side": "INTEGER DEFAULT 512",
|
||||||
|
"portrait_ratio": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"crop_center": "REAL NOT NULL DEFAULT 0.5",
|
||||||
|
"format": "TEXT NOT NULL DEFAULT 'MP4'",
|
||||||
|
"clip_count": "INTEGER NOT NULL DEFAULT 3",
|
||||||
|
"spread": "REAL NOT NULL DEFAULT 3.0",
|
||||||
|
"profile": "TEXT NOT NULL DEFAULT 'default'",
|
||||||
|
}
|
||||||
|
for col, typedef in new_cols.items():
|
||||||
|
if col not in cols:
|
||||||
|
self._con.execute(
|
||||||
|
f"ALTER TABLE processed ADD COLUMN {col} {typedef}"
|
||||||
|
)
|
||||||
|
self._con.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
|
||||||
|
)
|
||||||
|
self._con.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS hidden_files ("
|
||||||
|
" filename TEXT NOT NULL,"
|
||||||
|
" profile TEXT NOT NULL DEFAULT 'default',"
|
||||||
|
" PRIMARY KEY (filename, profile)"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
|
||||||
|
def add(self, filename: str, start_time: float, output_path: str,
|
||||||
|
label: str = "", category: str = "",
|
||||||
|
short_side: int | None = None, portrait_ratio: str = "",
|
||||||
|
crop_center: float = 0.5, fmt: str = "MP4",
|
||||||
|
clip_count: int = 3, spread: float = 3.0,
|
||||||
|
profile: str = "default") -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
self._con.execute(
|
||||||
|
"INSERT INTO processed"
|
||||||
|
" (filename, start_time, output_path, label, category,"
|
||||||
|
" short_side, portrait_ratio, crop_center, format,"
|
||||||
|
" clip_count, spread, profile, processed_at)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(filename, start_time, output_path, label, category,
|
||||||
|
short_side, portrait_ratio, crop_center, fmt,
|
||||||
|
clip_count, spread, profile,
|
||||||
|
datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
|
||||||
|
def get_labels(self) -> list[str]:
|
||||||
|
"""Return distinct non-empty labels ordered by most recently used."""
|
||||||
|
if not self._enabled:
|
||||||
|
return []
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT DISTINCT label FROM processed"
|
||||||
|
" WHERE label != '' ORDER BY processed_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
# Deduplicate while preserving order (DISTINCT on processed_at DESC
|
||||||
|
# may return duplicates if the same label was used multiple times).
|
||||||
|
seen: set[str] = set()
|
||||||
|
result = []
|
||||||
|
for (lbl,) in rows:
|
||||||
|
if lbl not in seen:
|
||||||
|
seen.add(lbl)
|
||||||
|
result.append(lbl)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_by_output_path(self, output_path: str) -> dict | None:
|
||||||
|
"""Return config dict for an output_path, or None."""
|
||||||
|
if not self._enabled:
|
||||||
|
return None
|
||||||
|
self._con.row_factory = sqlite3.Row
|
||||||
|
row = self._con.execute(
|
||||||
|
"SELECT label, category, short_side, portrait_ratio, crop_center, format,"
|
||||||
|
" clip_count, spread"
|
||||||
|
" FROM processed WHERE output_path = ?",
|
||||||
|
(output_path,),
|
||||||
|
).fetchone()
|
||||||
|
self._con.row_factory = None
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def delete_by_output_path(self, output_path: str) -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
self._con.execute("DELETE FROM processed WHERE output_path = ?", (output_path,))
|
||||||
|
self._con.commit()
|
||||||
|
|
||||||
|
def get_group(self, output_path: str) -> list[str]:
|
||||||
|
"""Return all output_paths sharing the same (filename, start_time) as *output_path*."""
|
||||||
|
if not self._enabled:
|
||||||
|
return []
|
||||||
|
row = self._con.execute(
|
||||||
|
"SELECT filename, start_time FROM processed WHERE output_path = ?",
|
||||||
|
(output_path,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return []
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT output_path FROM processed"
|
||||||
|
" WHERE filename = ? AND start_time = ? ORDER BY output_path",
|
||||||
|
(row[0], row[1]),
|
||||||
|
).fetchall()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
def delete_group(self, output_path: str) -> list[str]:
|
||||||
|
"""Delete all rows sharing the same (filename, start_time) as *output_path*.
|
||||||
|
Returns list of deleted output_paths."""
|
||||||
|
if not self._enabled:
|
||||||
|
return []
|
||||||
|
row = self._con.execute(
|
||||||
|
"SELECT filename, start_time FROM processed WHERE output_path = ?",
|
||||||
|
(output_path,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return []
|
||||||
|
filename, start_time = row
|
||||||
|
paths = [r[0] for r in self._con.execute(
|
||||||
|
"SELECT output_path FROM processed WHERE filename = ? AND start_time = ?",
|
||||||
|
(filename, start_time),
|
||||||
|
).fetchall()]
|
||||||
|
self._con.execute(
|
||||||
|
"DELETE FROM processed WHERE filename = ? AND start_time = ?",
|
||||||
|
(filename, start_time),
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def _get_markers_for(self, match: str, profile: str = "default") -> list[tuple[float, int, str]]:
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT start_time, output_path FROM processed"
|
||||||
|
" WHERE filename = ? AND profile = ? ORDER BY start_time",
|
||||||
|
(match, profile),
|
||||||
|
).fetchall()
|
||||||
|
# Deduplicate by start_time — batch exports share the same cursor.
|
||||||
|
seen_times: dict[float, tuple[float, int, str]] = {}
|
||||||
|
n = 0
|
||||||
|
for t, p in rows:
|
||||||
|
if t not in seen_times:
|
||||||
|
n += 1
|
||||||
|
seen_times[t] = (t, n, p)
|
||||||
|
return list(seen_times.values())
|
||||||
|
|
||||||
|
def get_markers(self, filename: str, profile: str = "default") -> list[tuple[float, int, str]]:
|
||||||
|
"""Return [(start_time, marker_number, output_path), ...] for exact
|
||||||
|
filename match, sorted by start_time. Empty list if no match."""
|
||||||
|
if not self._enabled:
|
||||||
|
return []
|
||||||
|
return self._get_markers_for(filename, profile)
|
||||||
|
|
||||||
|
def get_profiles(self) -> list[str]:
|
||||||
|
"""Return distinct profile names, ordered alphabetically."""
|
||||||
|
if not self._enabled:
|
||||||
|
return []
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT DISTINCT profile FROM processed ORDER BY profile"
|
||||||
|
).fetchall()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
def hide_file(self, filename: str, profile: str = "default") -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
self._con.execute(
|
||||||
|
"INSERT OR IGNORE INTO hidden_files (filename, profile) VALUES (?, ?)",
|
||||||
|
(filename, profile),
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
|
||||||
|
def unhide_file(self, filename: str, profile: str = "default") -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
self._con.execute(
|
||||||
|
"DELETE FROM hidden_files WHERE filename = ? AND profile = ?",
|
||||||
|
(filename, profile),
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
|
||||||
|
def get_hidden_files(self, profile: str = "default") -> set[str]:
|
||||||
|
if not self._enabled:
|
||||||
|
return set()
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT filename FROM hidden_files WHERE profile = ?", (profile,)
|
||||||
|
).fetchall()
|
||||||
|
return {r[0] for r in rows}
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .ffmpeg import build_ffmpeg_command, build_audio_extract_command
|
||||||
|
from .paths import _log
|
||||||
|
|
||||||
|
|
||||||
|
class ExportRunner:
|
||||||
|
"""Run ffmpeg export jobs in a background thread pool.
|
||||||
|
|
||||||
|
Callbacks:
|
||||||
|
on_clip_done(path: str)
|
||||||
|
on_all_done()
|
||||||
|
on_error(msg: str)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
input_path: str,
|
||||||
|
jobs: list[tuple[float, str, str | None, float]],
|
||||||
|
short_side: int | None = None,
|
||||||
|
image_sequence: bool = False,
|
||||||
|
max_workers: int | None = None,
|
||||||
|
encoder: str = "libx264",
|
||||||
|
on_clip_done: Callable[[str], None] | None = None,
|
||||||
|
on_all_done: Callable[[], None] | None = None,
|
||||||
|
on_error: Callable[[str], None] | None = None,
|
||||||
|
):
|
||||||
|
self._input = input_path
|
||||||
|
self._jobs = jobs
|
||||||
|
self._short_side = short_side
|
||||||
|
self._image_sequence = image_sequence
|
||||||
|
self._max_workers = max_workers
|
||||||
|
self._encoder = encoder
|
||||||
|
self._on_clip_done = on_clip_done
|
||||||
|
self._on_all_done = on_all_done
|
||||||
|
self._on_error = on_error
|
||||||
|
self._cancel = False
|
||||||
|
self._procs: list[subprocess.Popen] = []
|
||||||
|
self._procs_lock = threading.Lock()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._cancel = True
|
||||||
|
with self._procs_lock:
|
||||||
|
for proc in self._procs:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._thread is not None and self._thread.is_alive()
|
||||||
|
|
||||||
|
def _run_one(self, start: float, output: str,
|
||||||
|
portrait_ratio: str | None, crop_center: float) -> str:
|
||||||
|
if self._cancel:
|
||||||
|
raise RuntimeError("cancelled")
|
||||||
|
if self._image_sequence:
|
||||||
|
os.makedirs(output, exist_ok=True)
|
||||||
|
cmd = build_ffmpeg_command(
|
||||||
|
self._input, start, output,
|
||||||
|
short_side=self._short_side,
|
||||||
|
portrait_ratio=portrait_ratio,
|
||||||
|
crop_center=crop_center,
|
||||||
|
image_sequence=self._image_sequence,
|
||||||
|
encoder=self._encoder,
|
||||||
|
)
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
with self._procs_lock:
|
||||||
|
self._procs.append(proc)
|
||||||
|
try:
|
||||||
|
_, stderr = proc.communicate(timeout=120)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
raise RuntimeError("ffmpeg timed out")
|
||||||
|
finally:
|
||||||
|
with self._procs_lock:
|
||||||
|
self._procs.remove(proc)
|
||||||
|
if self._cancel:
|
||||||
|
raise RuntimeError("cancelled")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
msg = stderr.decode(errors='replace')[-500:] if stderr else "ffmpeg failed"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
if self._image_sequence:
|
||||||
|
audio_cmd = build_audio_extract_command(self._input, start, output)
|
||||||
|
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
cap = self._max_workers or (os.cpu_count() or 2)
|
||||||
|
workers = min(len(self._jobs), cap)
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
futures = {
|
||||||
|
pool.submit(self._run_one, s, o, pr, cc): o
|
||||||
|
for s, o, pr, cc in self._jobs
|
||||||
|
}
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
if self._cancel:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
path = fut.result()
|
||||||
|
if self._on_clip_done:
|
||||||
|
self._on_clip_done(path)
|
||||||
|
except Exception as e:
|
||||||
|
if "cancelled" not in str(e) and self._on_error:
|
||||||
|
self._on_error(str(e))
|
||||||
|
except Exception as e:
|
||||||
|
if self._on_error:
|
||||||
|
self._on_error(str(e))
|
||||||
|
return
|
||||||
|
if self._cancel:
|
||||||
|
return
|
||||||
|
if self._on_all_done:
|
||||||
|
self._on_all_done()
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .paths import _bin, _log
|
||||||
|
|
||||||
|
|
||||||
|
_RATIOS: dict[str, tuple[int, int]] = {
|
||||||
|
"9:16": (9, 16),
|
||||||
|
"4:5": (4, 5),
|
||||||
|
"1:1": (1, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _portrait_crop_filter(ratio: str, crop_center: float) -> str:
|
||||||
|
"""Return an ffmpeg crop= filter expression for the given portrait ratio.
|
||||||
|
|
||||||
|
Uses ffmpeg expression syntax so source dimensions are resolved at runtime.
|
||||||
|
Commas inside min()/max() are escaped with \\, to prevent ffmpeg's
|
||||||
|
filtergraph parser from treating them as filter-chain separators.
|
||||||
|
"""
|
||||||
|
num, den = _RATIOS[ratio]
|
||||||
|
cw = f"ih*{num}/{den}"
|
||||||
|
x = f"max(0\\,min((iw-{cw})*{crop_center}\\,iw-{cw}))"
|
||||||
|
return f"crop={cw}:ih:{x}:0"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_keyframe(
|
||||||
|
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
||||||
|
t: float,
|
||||||
|
tolerance: float = 0.05,
|
||||||
|
) -> tuple[float, float, str | None, bool, bool] | None:
|
||||||
|
"""Return the latest keyframe at or before *t*, or None."""
|
||||||
|
result = None
|
||||||
|
for kf in keyframes:
|
||||||
|
if kf[0] <= t + tolerance:
|
||||||
|
result = kf
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_keyframes_to_jobs(
|
||||||
|
jobs: list[tuple[float, str, str | None, float]],
|
||||||
|
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
||||||
|
base_center: float,
|
||||||
|
base_ratio: str | None,
|
||||||
|
base_rand_p: bool,
|
||||||
|
base_rand_s: bool,
|
||||||
|
) -> list[tuple[float, str, str | None, float, bool, bool]]:
|
||||||
|
"""Resolve each job's crop state from keyframes, returning widened tuples.
|
||||||
|
|
||||||
|
Returns list of (start, path, ratio, center, rand_portrait, rand_square).
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for s, o, _r, _c in jobs:
|
||||||
|
kf = resolve_keyframe(keyframes, s)
|
||||||
|
if kf is not None:
|
||||||
|
_, center, ratio, rp, rs = kf
|
||||||
|
else:
|
||||||
|
center, ratio, rp, rs = base_center, base_ratio, base_rand_p, base_rand_s
|
||||||
|
result.append((s, o, ratio, center, rp, rs))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_ffmpeg_command(
|
||||||
|
input_path: str, start: float, output_path: str,
|
||||||
|
short_side: int | None = None,
|
||||||
|
portrait_ratio: str | None = None,
|
||||||
|
crop_center: float = 0.5,
|
||||||
|
image_sequence: bool = False,
|
||||||
|
encoder: str = "libx264",
|
||||||
|
) -> list[str]:
|
||||||
|
# -ss before -i: fast input-seeking. Safe here because we always re-encode,
|
||||||
|
# so there is no keyframe-alignment issue from pre-input seek.
|
||||||
|
# Image sequences always use libwebp, so skip HW encoder setup.
|
||||||
|
use_hw_vaapi = encoder == "h264_vaapi" and not image_sequence
|
||||||
|
cmd = [_bin("ffmpeg"), "-y"]
|
||||||
|
|
||||||
|
# VAAPI needs a device for hardware context.
|
||||||
|
if use_hw_vaapi:
|
||||||
|
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
|
||||||
|
"-vaapi_device", "/dev/dri/renderD128"]
|
||||||
|
|
||||||
|
cmd += [
|
||||||
|
"-threads", "0",
|
||||||
|
"-ss", str(start),
|
||||||
|
"-i", input_path,
|
||||||
|
"-t", "8",
|
||||||
|
]
|
||||||
|
|
||||||
|
filters: list[str] = []
|
||||||
|
if portrait_ratio is not None:
|
||||||
|
filters.append(_portrait_crop_filter(portrait_ratio, crop_center))
|
||||||
|
if short_side is not None:
|
||||||
|
# Scale so the shorter dimension equals short_side.
|
||||||
|
filters.append(
|
||||||
|
f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})':flags=lanczos"
|
||||||
|
)
|
||||||
|
|
||||||
|
# VAAPI: decoded frames are GPU surfaces. CPU filters need hwdownload first.
|
||||||
|
if use_hw_vaapi:
|
||||||
|
if filters:
|
||||||
|
filters.insert(0, "hwdownload")
|
||||||
|
filters.insert(1, "format=nv12")
|
||||||
|
filters.append("format=nv12")
|
||||||
|
filters.append("hwupload")
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
cmd += ["-vf", ",".join(filters)]
|
||||||
|
|
||||||
|
if image_sequence:
|
||||||
|
cmd += [
|
||||||
|
"-an",
|
||||||
|
"-c:v", "libwebp",
|
||||||
|
"-quality", "92",
|
||||||
|
"-compression_level", "1",
|
||||||
|
os.path.join(output_path, "frame_%04d.webp"),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
cmd += ["-c:v", encoder, "-c:a", "pcm_s16le", output_path]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str) -> list[str]:
|
||||||
|
"""Return an ffmpeg command that extracts audio to <sequence_dir>.wav."""
|
||||||
|
audio_path = sequence_dir + ".wav"
|
||||||
|
return [
|
||||||
|
_bin("ffmpeg"), "-y",
|
||||||
|
"-ss", str(start),
|
||||||
|
"-i", input_path,
|
||||||
|
"-t", "8",
|
||||||
|
"-vn",
|
||||||
|
"-c:a", "pcm_s16le",
|
||||||
|
audio_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_hw_encoders() -> list[str]:
|
||||||
|
"""Probe ffmpeg for available H.264 hardware encoders."""
|
||||||
|
_HW_ENCODERS = ["h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox"]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[_bin("ffmpeg"), "-hide_banner", "-encoders"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
output = result.stdout
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
available = []
|
||||||
|
for enc in _HW_ENCODERS:
|
||||||
|
if re.search(rf'\b{enc}\b', output):
|
||||||
|
available.append(enc)
|
||||||
|
if available:
|
||||||
|
_log(f"HW encoders detected: {', '.join(available)}")
|
||||||
|
else:
|
||||||
|
_log("No HW encoders detected — GPU export unavailable")
|
||||||
|
return available
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _frozen_path() -> Path:
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return Path(sys._MEIPASS)
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _bin(name: str) -> str:
|
||||||
|
"""Resolve a binary name (e.g. 'ffmpeg') to its full path in frozen builds."""
|
||||||
|
p = _frozen_path() / name
|
||||||
|
if p.exists():
|
||||||
|
return str(p)
|
||||||
|
return name # fall back to PATH
|
||||||
|
|
||||||
|
|
||||||
|
def _log(*args) -> None:
|
||||||
|
"""Print a timestamped log line to stderr."""
|
||||||
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
|
print(f"[8-cut {ts}]", *args, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def build_export_path(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
||||||
|
group = f"{basename}_{counter:03d}"
|
||||||
|
name = f"{group}_{sub}" if sub is not None else group
|
||||||
|
return os.path.join(folder, group, name + ".mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def build_sequence_dir(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
||||||
|
group = f"{basename}_{counter:03d}"
|
||||||
|
name = f"{group}_{sub}" if sub is not None else group
|
||||||
|
return os.path.join(folder, group, name)
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(seconds: float) -> str:
|
||||||
|
m = int(seconds // 60)
|
||||||
|
# Floor-truncate to 1 dp (not round) — prevents "X:60.0" rollover when
|
||||||
|
# seconds is e.g. 59.95.
|
||||||
|
s = int(seconds % 60 * 10) / 10
|
||||||
|
return f"{m}:{s:04.1f}"
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from .paths import _bin, _log
|
||||||
|
|
||||||
|
_yolo_model = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_yolo():
|
||||||
|
"""Lazy-load YOLOv8-nano. Returns None if ultralytics is not installed."""
|
||||||
|
global _yolo_model
|
||||||
|
if _yolo_model is None:
|
||||||
|
try:
|
||||||
|
from ultralytics import YOLO
|
||||||
|
_yolo_model = YOLO("yolov8n.pt")
|
||||||
|
_log("YOLO model loaded")
|
||||||
|
except ImportError:
|
||||||
|
_log("ultralytics not installed — tracking disabled")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"YOLO load failed: {e}")
|
||||||
|
return None
|
||||||
|
return _yolo_model
|
||||||
|
|
||||||
|
|
||||||
|
def extract_frame_cv(video_path: str, time: float):
|
||||||
|
"""Extract a single frame as a numpy array (BGR) via ffmpeg -> temp PNG -> cv2."""
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
fd, tmp = tempfile.mkstemp(suffix=".png")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
cmd = [_bin("ffmpeg"), "-y", "-ss", str(time), "-i", video_path,
|
||||||
|
"-frames:v", "1", tmp]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return cv2.imread(tmp)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_subject_center(
|
||||||
|
video_path: str, time: float, target_cls: int | None, last_x: float, last_y: float,
|
||||||
|
) -> tuple[int | None, float, float] | None:
|
||||||
|
"""Detect objects at *time* and return (class_id, norm_x, norm_y) of the
|
||||||
|
best match to (target_cls, last_x, last_y). Returns None on failure."""
|
||||||
|
model = _get_yolo()
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
frame = extract_frame_cv(video_path, time)
|
||||||
|
if frame is None:
|
||||||
|
return None
|
||||||
|
results = model(frame, verbose=False)
|
||||||
|
if not results or len(results[0].boxes) == 0:
|
||||||
|
return None
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
dets = []
|
||||||
|
for box in results[0].boxes:
|
||||||
|
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||||
|
cls = int(box.cls[0])
|
||||||
|
cx = (x1 + x2) / 2 / w
|
||||||
|
cy = (y1 + y2) / 2 / h
|
||||||
|
dets.append((cls, cx, cy))
|
||||||
|
# Prefer same class, nearest to last known position.
|
||||||
|
def score(d):
|
||||||
|
cls_penalty = 0 if (target_cls is None or d[0] == target_cls) else 1.0
|
||||||
|
dist = (d[1] - last_x) ** 2 + (d[2] - last_y) ** 2
|
||||||
|
return cls_penalty + dist
|
||||||
|
best = min(dets, key=score)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def track_centers_for_jobs(
|
||||||
|
video_path: str, cursor: float, crop_center: float,
|
||||||
|
starts: list[float],
|
||||||
|
) -> list[float]:
|
||||||
|
"""Run detection at the cursor (to identify the target) then at each start
|
||||||
|
time. Returns a list of horizontal crop centers (one per start)."""
|
||||||
|
ref = detect_subject_center(video_path, cursor, None, crop_center, 0.5)
|
||||||
|
if ref is None:
|
||||||
|
_log("Tracking: no detection at cursor, using fixed center")
|
||||||
|
return [crop_center] * len(starts)
|
||||||
|
target_cls, last_x, last_y = ref
|
||||||
|
_log(f"Tracking: target class={target_cls} at ({last_x:.2f}, {last_y:.2f})")
|
||||||
|
centers = []
|
||||||
|
for t in starts:
|
||||||
|
det = detect_subject_center(video_path, t, target_cls, last_x, last_y)
|
||||||
|
if det is not None:
|
||||||
|
_, cx, cy = det
|
||||||
|
_log(f" t={t:.2f}s → center={cx:.3f}")
|
||||||
|
centers.append(cx)
|
||||||
|
last_x, last_y = cx, cy
|
||||||
|
else:
|
||||||
|
_log(f" t={t:.2f}s → lost, reusing {last_x:.3f}")
|
||||||
|
centers.append(last_x)
|
||||||
|
return centers
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# 8-cut Server API Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Run 8-cut as a FastAPI server on Unraid (Docker) so a Tauri desktop client on Mac can edit remotely over WireGuard — no file transfers, no auth.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Unraid (Docker container):
|
||||||
|
FastAPI + ffmpeg + SQLite
|
||||||
|
├── /api/files list videos from mounted volumes
|
||||||
|
├── /api/stream/{path} transcoded video (cached, no audio)
|
||||||
|
├── /api/audio/{path} full-quality audio (cached, passthrough)
|
||||||
|
├── /api/video/{path} raw file (for reference/download)
|
||||||
|
├── /api/markers CRUD markers per profile
|
||||||
|
├── /api/profiles list/create profiles
|
||||||
|
├── /api/export trigger + manage exports
|
||||||
|
├── /api/labels label history
|
||||||
|
├── /api/hidden hidden file management
|
||||||
|
└── ws://…/ws/export real-time export progress
|
||||||
|
|
||||||
|
Mac (Tauri + Svelte + libmpv):
|
||||||
|
├── mpv plays stream URL (video) + audio URL separately
|
||||||
|
├── Canvas timeline + crop overlay + keyframes
|
||||||
|
├── Full UI: profiles, subprofiles, settings
|
||||||
|
└── Stateless — all state lives on server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker mounts
|
||||||
|
|
||||||
|
| Mount | Purpose | Env var |
|
||||||
|
|-------------|--------------------------------|--------------|
|
||||||
|
| `/videos` | Source video files (read-only) | `MEDIA_DIRS` |
|
||||||
|
| `/exports` | Export output | `EXPORT_DIR` |
|
||||||
|
| `/data` | SQLite DB + transcode cache | `DB_PATH`, `CACHE_DIR` |
|
||||||
|
|
||||||
|
`MEDIA_DIRS` supports multiple paths: `/videos1,/videos2`.
|
||||||
|
|
||||||
|
## Video streaming with transcode cache
|
||||||
|
|
||||||
|
The client needs low-bitrate video for scrubbing over the network but full-quality audio for accurate editing.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Client requests `/api/stream/{path}?quality=low`
|
||||||
|
2. Server checks cache: `{CACHE_DIR}/{quality}/{hash}.mp4`
|
||||||
|
3. If cached → serve with range requests (instant seeking)
|
||||||
|
4. If not → start background ffmpeg transcode, return `202 Accepted` with job ID
|
||||||
|
5. Client polls or gets WebSocket notification when ready
|
||||||
|
6. Audio: `/api/audio/{path}` extracts audio (passthrough, fast) to cache on first request
|
||||||
|
|
||||||
|
**Quality presets:**
|
||||||
|
|
||||||
|
| Preset | Resolution | Bitrate |
|
||||||
|
|----------|-----------|----------|
|
||||||
|
| `potato` | 480p | ~500 Kbps |
|
||||||
|
| `low` | 720p | ~2 Mbps |
|
||||||
|
| `medium` | 1080p | ~5 Mbps |
|
||||||
|
| `high` | original | ~10 Mbps |
|
||||||
|
|
||||||
|
Each quality level cached separately. Client can switch quality — mpv reloads the URL.
|
||||||
|
|
||||||
|
**mpv on client:**
|
||||||
|
```
|
||||||
|
video = http://server/api/stream/file.mp4?quality=low
|
||||||
|
audio = http://server/api/audio/file.mp4
|
||||||
|
```
|
||||||
|
mpv's `--audio-file=` flag plays both in sync with frame-accurate seeking.
|
||||||
|
|
||||||
|
## API endpoints
|
||||||
|
|
||||||
|
### Files
|
||||||
|
```
|
||||||
|
GET /api/files?root={root}
|
||||||
|
→ [{path, name, size, duration?, markers_count}]
|
||||||
|
|
||||||
|
GET /api/video/{path}
|
||||||
|
→ raw file with range requests
|
||||||
|
|
||||||
|
GET /api/stream/{path}?quality=low|medium|high|potato
|
||||||
|
→ cached transcoded video (no audio), range requests
|
||||||
|
→ 202 if transcode in progress
|
||||||
|
|
||||||
|
GET /api/audio/{path}
|
||||||
|
→ cached full-quality audio, range requests
|
||||||
|
→ 202 if extraction in progress
|
||||||
|
|
||||||
|
GET /api/cache/status/{path}
|
||||||
|
→ {qualities: {potato: "ready", low: "transcoding", ...}, audio: "ready"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markers & profiles
|
||||||
|
```
|
||||||
|
GET /api/markers/{filename}?profile=default
|
||||||
|
→ [{start_time, marker_number, output_path}]
|
||||||
|
|
||||||
|
GET /api/profiles
|
||||||
|
→ ["default", "intense", ...]
|
||||||
|
|
||||||
|
GET /api/labels
|
||||||
|
→ ["dog barking", "rain", ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export
|
||||||
|
```
|
||||||
|
POST /api/export
|
||||||
|
body: {input_path, cursor, folder_suffix?, name, clips, spread,
|
||||||
|
short_side?, portrait_ratio?, crop_center, format,
|
||||||
|
label?, category?, profile, crop_keyframes?,
|
||||||
|
rand_portrait?, rand_square?, track_subject?}
|
||||||
|
→ {job_id}
|
||||||
|
|
||||||
|
GET /api/export/{job_id}
|
||||||
|
→ {status, completed, total, outputs: [...]}
|
||||||
|
|
||||||
|
DELETE /api/export/{output_path}
|
||||||
|
→ delete from DB + disk
|
||||||
|
|
||||||
|
WS /ws/export
|
||||||
|
→ server pushes: {type: "clip_done", path: "..."} | {type: "all_done"} | {type: "error", msg: "..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hidden files
|
||||||
|
```
|
||||||
|
POST /api/hidden/{filename}?profile=default
|
||||||
|
DELETE /api/hidden/{filename}?profile=default
|
||||||
|
GET /api/hidden?profile=default
|
||||||
|
→ ["file1.mp4", "file2.mp4"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code reuse from main.py
|
||||||
|
|
||||||
|
**Extracted to shared module (used by both server and Qt app):**
|
||||||
|
- `ProcessedDB` — SQLite operations
|
||||||
|
- `build_ffmpeg_command` — ffmpeg command construction
|
||||||
|
- `build_audio_extract_command`
|
||||||
|
- `build_export_path` / `build_sequence_dir`
|
||||||
|
- `detect_hw_encoders`
|
||||||
|
- `upsert_clip_annotation` / `remove_clip_annotation`
|
||||||
|
- `apply_keyframes_to_jobs` / `resolve_keyframe`
|
||||||
|
- `track_centers_for_jobs` (subject tracking)
|
||||||
|
|
||||||
|
**Server-specific (new):**
|
||||||
|
- FastAPI app + route handlers
|
||||||
|
- Transcode cache manager
|
||||||
|
- Export worker (plain threading, replaces QThread-based ExportWorker)
|
||||||
|
- File listing / media root scanning
|
||||||
|
- WebSocket export progress broadcaster
|
||||||
|
|
||||||
|
**Tauri client (new, Svelte):**
|
||||||
|
- mpv integration via Tauri plugin or sidecar
|
||||||
|
- Canvas-based timeline widget
|
||||||
|
- Canvas-based crop overlay
|
||||||
|
- All UI controls
|
||||||
|
- API client module
|
||||||
|
|
||||||
|
## Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.12-slim
|
||||||
|
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server/ .
|
||||||
|
RUN pip install --no-cache-dir fastapi uvicorn
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
8-cut/
|
||||||
|
├── main.py (existing Qt app, unchanged)
|
||||||
|
├── core/ (shared logic, extracted from main.py)
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── db.py (ProcessedDB)
|
||||||
|
│ ├── ffmpeg.py (build commands, detect encoders)
|
||||||
|
│ ├── export.py (ExportWorker — plain threading)
|
||||||
|
│ ├── paths.py (build_export_path, build_sequence_dir)
|
||||||
|
│ └── annotations.py (dataset.json helpers)
|
||||||
|
├── server/
|
||||||
|
│ ├── app.py (FastAPI app)
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── files.py
|
||||||
|
│ │ ├── stream.py
|
||||||
|
│ │ ├── markers.py
|
||||||
|
│ │ ├── export.py
|
||||||
|
│ │ └── hidden.py
|
||||||
|
│ ├── cache.py (transcode cache manager)
|
||||||
|
│ ├── ws.py (WebSocket handler)
|
||||||
|
│ └── config.py (env vars, settings)
|
||||||
|
├── client/ (Tauri + Svelte — future)
|
||||||
|
│ └── ...
|
||||||
|
├── Dockerfile
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. Extract shared logic from main.py → `core/`
|
||||||
|
2. Update main.py to import from `core/` (verify Qt app still works)
|
||||||
|
3. Build FastAPI server with file listing + video serving
|
||||||
|
4. Add transcode cache + audio extraction
|
||||||
|
5. Add markers/profiles/labels/hidden API
|
||||||
|
6. Add export endpoint + WebSocket progress
|
||||||
|
7. Dockerfile + docker-compose
|
||||||
|
8. (Later) Tauri client
|
||||||
@@ -0,0 +1,948 @@
|
|||||||
|
# Server API Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Extract shared logic from main.py into a `core/` package, then build the FastAPI server that serves video files, manages the DB, and runs exports.
|
||||||
|
|
||||||
|
**Architecture:** Shared logic (DB, ffmpeg, paths, annotations, tracking) moves to `core/`. Both `main.py` (Qt app) and `server/` import from `core/`. The server adds HTTP video streaming with transcode cache, REST endpoints, and WebSocket export progress.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, FastAPI, uvicorn, SQLite, ffmpeg
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create core/ package — paths and helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `core/__init__.py`
|
||||||
|
- Create: `core/paths.py`
|
||||||
|
|
||||||
|
**Step 1: Create core/__init__.py**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# empty — package marker
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create core/paths.py**
|
||||||
|
|
||||||
|
Extract from main.py lines 36-74: `_frozen_path`, `_bin`, `_log`, `build_export_path`, `build_sequence_dir`, `format_time`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _frozen_path() -> Path:
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return Path(sys._MEIPASS)
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _bin(name: str) -> str:
|
||||||
|
p = _frozen_path() / name
|
||||||
|
if p.exists():
|
||||||
|
return str(p)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _log(*args) -> None:
|
||||||
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
|
print(f"[8-cut {ts}]", *args, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def build_export_path(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
||||||
|
group = f"{basename}_{counter:03d}"
|
||||||
|
name = f"{group}_{sub}" if sub is not None else group
|
||||||
|
return os.path.join(folder, group, name + ".mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def build_sequence_dir(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
||||||
|
group = f"{basename}_{counter:03d}"
|
||||||
|
name = f"{group}_{sub}" if sub is not None else group
|
||||||
|
return os.path.join(folder, group, name)
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(seconds: float) -> str:
|
||||||
|
m = int(seconds // 60)
|
||||||
|
s = int(seconds % 60 * 10) / 10
|
||||||
|
return f"{m}:{s:04.1f}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add core/
|
||||||
|
git commit -m "feat: create core/paths module with shared path helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create core/ffmpeg.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `core/ffmpeg.py`
|
||||||
|
|
||||||
|
**Step 1: Create core/ffmpeg.py**
|
||||||
|
|
||||||
|
Extract from main.py lines 77-112 and 244-289: `_RATIOS`, `_portrait_crop_filter`, `resolve_keyframe`, `apply_keyframes_to_jobs`, `build_ffmpeg_command`, `build_audio_extract_command`, `detect_hw_encoders`. (Lines 115-188 are also ffmpeg-related. Lines 191-241 are annotations — extracted separately in Task 4.)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .paths import _bin, _log
|
||||||
|
|
||||||
|
|
||||||
|
_RATIOS: dict[str, tuple[int, int]] = {
|
||||||
|
"9:16": (9, 16),
|
||||||
|
"4:5": (4, 5),
|
||||||
|
"1:1": (1, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _portrait_crop_filter(ratio: str, crop_center: float) -> str:
|
||||||
|
num, den = _RATIOS[ratio]
|
||||||
|
cw = f"ih*{num}/{den}"
|
||||||
|
x = f"max(0\\,min((iw-{cw})*{crop_center}\\,iw-{cw}))"
|
||||||
|
return f"crop={cw}:ih:{x}:0"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_keyframe(
|
||||||
|
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
||||||
|
t: float,
|
||||||
|
tolerance: float = 0.05,
|
||||||
|
) -> tuple[float, float, str | None, bool, bool] | None:
|
||||||
|
result = None
|
||||||
|
for kf in keyframes:
|
||||||
|
if kf[0] <= t + tolerance:
|
||||||
|
result = kf
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def apply_keyframes_to_jobs(
|
||||||
|
jobs: list[tuple[float, str, str | None, float]],
|
||||||
|
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
||||||
|
base_center: float,
|
||||||
|
base_ratio: str | None,
|
||||||
|
base_rand_p: bool,
|
||||||
|
base_rand_s: bool,
|
||||||
|
) -> list[tuple[float, str, str | None, float, bool, bool]]:
|
||||||
|
result = []
|
||||||
|
for s, o, _r, _c in jobs:
|
||||||
|
kf = resolve_keyframe(keyframes, s)
|
||||||
|
if kf is not None:
|
||||||
|
_, center, ratio, rp, rs = kf
|
||||||
|
else:
|
||||||
|
center, ratio, rp, rs = base_center, base_ratio, base_rand_p, base_rand_s
|
||||||
|
result.append((s, o, ratio, center, rp, rs))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_ffmpeg_command(
|
||||||
|
input_path: str, start: float, output_path: str,
|
||||||
|
short_side: int | None = None,
|
||||||
|
portrait_ratio: str | None = None,
|
||||||
|
crop_center: float = 0.5,
|
||||||
|
image_sequence: bool = False,
|
||||||
|
encoder: str = "libx264",
|
||||||
|
) -> list[str]:
|
||||||
|
use_hw_vaapi = encoder == "h264_vaapi" and not image_sequence
|
||||||
|
cmd = [_bin("ffmpeg"), "-y"]
|
||||||
|
if use_hw_vaapi:
|
||||||
|
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
|
||||||
|
"-vaapi_device", "/dev/dri/renderD128"]
|
||||||
|
cmd += ["-threads", "0", "-ss", str(start), "-i", input_path, "-t", "8"]
|
||||||
|
filters: list[str] = []
|
||||||
|
if portrait_ratio is not None:
|
||||||
|
filters.append(_portrait_crop_filter(portrait_ratio, crop_center))
|
||||||
|
if short_side is not None:
|
||||||
|
filters.append(
|
||||||
|
f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})':flags=lanczos"
|
||||||
|
)
|
||||||
|
if use_hw_vaapi:
|
||||||
|
if filters:
|
||||||
|
filters.insert(0, "hwdownload")
|
||||||
|
filters.insert(1, "format=nv12")
|
||||||
|
filters.append("format=nv12")
|
||||||
|
filters.append("hwupload")
|
||||||
|
if filters:
|
||||||
|
cmd += ["-vf", ",".join(filters)]
|
||||||
|
if image_sequence:
|
||||||
|
cmd += ["-an", "-c:v", "libwebp", "-quality", "92", "-compression_level", "1",
|
||||||
|
os.path.join(output_path, "frame_%04d.webp")]
|
||||||
|
else:
|
||||||
|
cmd += ["-c:v", encoder, "-c:a", "pcm_s16le", output_path]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str) -> list[str]:
|
||||||
|
audio_path = sequence_dir + ".wav"
|
||||||
|
return [_bin("ffmpeg"), "-y", "-ss", str(start), "-i", input_path,
|
||||||
|
"-t", "8", "-vn", "-c:a", "pcm_s16le", audio_path]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_hw_encoders() -> list[str]:
|
||||||
|
_HW_ENCODERS = ["h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox"]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[_bin("ffmpeg"), "-hide_banner", "-encoders"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
output = result.stdout
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
available = []
|
||||||
|
for enc in _HW_ENCODERS:
|
||||||
|
if re.search(rf'\b{enc}\b', output):
|
||||||
|
available.append(enc)
|
||||||
|
if available:
|
||||||
|
_log(f"HW encoders detected: {', '.join(available)}")
|
||||||
|
else:
|
||||||
|
_log("No HW encoders detected — GPU export unavailable")
|
||||||
|
return available
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add core/ffmpeg.py
|
||||||
|
git commit -m "feat: create core/ffmpeg module with ffmpeg helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create core/db.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `core/db.py`
|
||||||
|
|
||||||
|
**Step 1: Create core/db.py**
|
||||||
|
|
||||||
|
Extract the entire `ProcessedDB` class from main.py lines 398-626. Import `_log` from `core.paths`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import _log
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedDB:
|
||||||
|
_SCHEMA_VERSION = 3
|
||||||
|
|
||||||
|
def __init__(self, db_path: str | None = None):
|
||||||
|
# ... exact copy of existing class ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the full class body verbatim — all methods unchanged.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add core/db.py
|
||||||
|
git commit -m "feat: create core/db module with ProcessedDB"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Create core/annotations.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `core/annotations.py`
|
||||||
|
|
||||||
|
**Step 1: Create core/annotations.py**
|
||||||
|
|
||||||
|
Extract from main.py lines 191-241: `build_annotation_json_path`, `remove_clip_annotation`, `upsert_clip_annotation`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def build_annotation_json_path(folder: str) -> str:
|
||||||
|
return os.path.join(folder, "dataset.json")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_clip_annotation(folder: str, clip_path: str) -> None:
|
||||||
|
json_path = build_annotation_json_path(folder)
|
||||||
|
if not os.path.exists(json_path):
|
||||||
|
return
|
||||||
|
abs_path = os.path.abspath(clip_path)
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
try:
|
||||||
|
entries = json.load(f)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return
|
||||||
|
entries = [e for e in entries if e.get("path") != abs_path]
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(entries, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_clip_annotation(folder: str, clip_path: str, label: str) -> None:
|
||||||
|
if not label.strip():
|
||||||
|
return
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
json_path = build_annotation_json_path(folder)
|
||||||
|
entries: list[dict] = []
|
||||||
|
if os.path.exists(json_path):
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
try:
|
||||||
|
entries = json.load(f)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
entries = []
|
||||||
|
abs_path = os.path.abspath(clip_path)
|
||||||
|
entry: dict = {"path": abs_path, "label": label}
|
||||||
|
for i, e in enumerate(entries):
|
||||||
|
if e.get("path") == abs_path:
|
||||||
|
entries[i] = entry
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
entries.append(entry)
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(entries, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add core/annotations.py
|
||||||
|
git commit -m "feat: create core/annotations module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Create core/export.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `core/export.py`
|
||||||
|
|
||||||
|
**Step 1: Create core/export.py**
|
||||||
|
|
||||||
|
A plain-threading version of `ExportWorker` (no QThread dependency). Used by the server. The Qt app continues using its own QThread-based worker.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .ffmpeg import build_ffmpeg_command, build_audio_extract_command
|
||||||
|
from .paths import _bin, _log
|
||||||
|
|
||||||
|
|
||||||
|
class ExportRunner:
|
||||||
|
"""Run ffmpeg export jobs in a background thread pool.
|
||||||
|
|
||||||
|
Callbacks:
|
||||||
|
on_clip_done(path: str)
|
||||||
|
on_all_done()
|
||||||
|
on_error(msg: str)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
input_path: str,
|
||||||
|
jobs: list[tuple[float, str, str | None, float]],
|
||||||
|
short_side: int | None = None,
|
||||||
|
image_sequence: bool = False,
|
||||||
|
max_workers: int | None = None,
|
||||||
|
encoder: str = "libx264",
|
||||||
|
on_clip_done: Callable[[str], None] | None = None,
|
||||||
|
on_all_done: Callable[[], None] | None = None,
|
||||||
|
on_error: Callable[[str], None] | None = None,
|
||||||
|
):
|
||||||
|
self._input = input_path
|
||||||
|
self._jobs = jobs
|
||||||
|
self._short_side = short_side
|
||||||
|
self._image_sequence = image_sequence
|
||||||
|
self._max_workers = max_workers
|
||||||
|
self._encoder = encoder
|
||||||
|
self._on_clip_done = on_clip_done
|
||||||
|
self._on_all_done = on_all_done
|
||||||
|
self._on_error = on_error
|
||||||
|
self._cancel = False
|
||||||
|
self._procs: list[subprocess.Popen] = []
|
||||||
|
self._procs_lock = threading.Lock()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._cancel = True
|
||||||
|
with self._procs_lock:
|
||||||
|
for proc in self._procs:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._thread is not None and self._thread.is_alive()
|
||||||
|
|
||||||
|
def _run_one(self, start: float, output: str,
|
||||||
|
portrait_ratio: str | None, crop_center: float) -> str:
|
||||||
|
if self._cancel:
|
||||||
|
raise RuntimeError("cancelled")
|
||||||
|
if self._image_sequence:
|
||||||
|
os.makedirs(output, exist_ok=True)
|
||||||
|
cmd = build_ffmpeg_command(
|
||||||
|
self._input, start, output,
|
||||||
|
short_side=self._short_side,
|
||||||
|
portrait_ratio=portrait_ratio,
|
||||||
|
crop_center=crop_center,
|
||||||
|
image_sequence=self._image_sequence,
|
||||||
|
encoder=self._encoder,
|
||||||
|
)
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
with self._procs_lock:
|
||||||
|
self._procs.append(proc)
|
||||||
|
try:
|
||||||
|
_, stderr = proc.communicate(timeout=120)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
raise RuntimeError("ffmpeg timed out")
|
||||||
|
finally:
|
||||||
|
with self._procs_lock:
|
||||||
|
self._procs.remove(proc)
|
||||||
|
if self._cancel:
|
||||||
|
raise RuntimeError("cancelled")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
msg = stderr.decode(errors='replace')[-500:] if stderr else "ffmpeg failed"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
if self._image_sequence:
|
||||||
|
audio_cmd = build_audio_extract_command(self._input, start, output)
|
||||||
|
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
cap = self._max_workers or (os.cpu_count() or 2)
|
||||||
|
workers = min(len(self._jobs), cap)
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
futures = {
|
||||||
|
pool.submit(self._run_one, s, o, pr, cc): o
|
||||||
|
for s, o, pr, cc in self._jobs
|
||||||
|
}
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
if self._cancel:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
path = fut.result()
|
||||||
|
if self._on_clip_done:
|
||||||
|
self._on_clip_done(path)
|
||||||
|
except Exception as e:
|
||||||
|
if "cancelled" not in str(e) and self._on_error:
|
||||||
|
self._on_error(str(e))
|
||||||
|
except Exception as e:
|
||||||
|
if self._on_error:
|
||||||
|
self._on_error(str(e))
|
||||||
|
return
|
||||||
|
if self._cancel:
|
||||||
|
return
|
||||||
|
if self._on_all_done:
|
||||||
|
self._on_all_done()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add core/export.py
|
||||||
|
git commit -m "feat: create core/export module with ExportRunner"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Create core/tracking.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `core/tracking.py`
|
||||||
|
|
||||||
|
**Step 1: Create core/tracking.py**
|
||||||
|
|
||||||
|
Extract from main.py lines 294-395: YOLO tracking functions.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from .paths import _bin, _log
|
||||||
|
|
||||||
|
_yolo_model = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_yolo():
|
||||||
|
global _yolo_model
|
||||||
|
if _yolo_model is None:
|
||||||
|
try:
|
||||||
|
from ultralytics import YOLO
|
||||||
|
_yolo_model = YOLO("yolov8n.pt")
|
||||||
|
_log("YOLO model loaded")
|
||||||
|
except ImportError:
|
||||||
|
_log("ultralytics not installed — tracking disabled")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"YOLO load failed: {e}")
|
||||||
|
return None
|
||||||
|
return _yolo_model
|
||||||
|
|
||||||
|
|
||||||
|
def extract_frame_cv(video_path: str, time: float):
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
fd, tmp = tempfile.mkstemp(suffix=".png")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
cmd = [_bin("ffmpeg"), "-y", "-ss", str(time), "-i", video_path,
|
||||||
|
"-frames:v", "1", tmp]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return cv2.imread(tmp)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_subject_center(
|
||||||
|
video_path: str, time: float, target_cls: int | None, last_x: float, last_y: float,
|
||||||
|
) -> tuple[int | None, float, float] | None:
|
||||||
|
model = _get_yolo()
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
frame = extract_frame_cv(video_path, time)
|
||||||
|
if frame is None:
|
||||||
|
return None
|
||||||
|
results = model(frame, verbose=False)
|
||||||
|
if not results or len(results[0].boxes) == 0:
|
||||||
|
return None
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
dets = []
|
||||||
|
for box in results[0].boxes:
|
||||||
|
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
||||||
|
cls = int(box.cls[0])
|
||||||
|
cx = (x1 + x2) / 2 / w
|
||||||
|
cy = (y1 + y2) / 2 / h
|
||||||
|
dets.append((cls, cx, cy))
|
||||||
|
def score(d):
|
||||||
|
cls_penalty = 0 if (target_cls is None or d[0] == target_cls) else 1.0
|
||||||
|
dist = (d[1] - last_x) ** 2 + (d[2] - last_y) ** 2
|
||||||
|
return cls_penalty + dist
|
||||||
|
best = min(dets, key=score)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def track_centers_for_jobs(
|
||||||
|
video_path: str, cursor: float, crop_center: float,
|
||||||
|
starts: list[float],
|
||||||
|
) -> list[float]:
|
||||||
|
ref = detect_subject_center(video_path, cursor, None, crop_center, 0.5)
|
||||||
|
if ref is None:
|
||||||
|
_log("Tracking: no detection at cursor, using fixed center")
|
||||||
|
return [crop_center] * len(starts)
|
||||||
|
target_cls, last_x, last_y = ref
|
||||||
|
_log(f"Tracking: target class={target_cls} at ({last_x:.2f}, {last_y:.2f})")
|
||||||
|
centers = []
|
||||||
|
for t in starts:
|
||||||
|
det = detect_subject_center(video_path, t, target_cls, last_x, last_y)
|
||||||
|
if det is not None:
|
||||||
|
_, cx, cy = det
|
||||||
|
_log(f" t={t:.2f}s → center={cx:.3f}")
|
||||||
|
centers.append(cx)
|
||||||
|
last_x, last_y = cx, cy
|
||||||
|
else:
|
||||||
|
_log(f" t={t:.2f}s → lost, reusing {last_x:.3f}")
|
||||||
|
centers.append(last_x)
|
||||||
|
return centers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add core/tracking.py
|
||||||
|
git commit -m "feat: create core/tracking module with YOLO subject tracking"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update main.py to import from core/
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `main.py`
|
||||||
|
|
||||||
|
**Step 1: Replace function definitions with imports**
|
||||||
|
|
||||||
|
At the top of main.py, after the existing stdlib imports (line 17), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.paths import _bin, _log, build_export_path, build_sequence_dir, format_time
|
||||||
|
from core.ffmpeg import (
|
||||||
|
_RATIOS, resolve_keyframe, apply_keyframes_to_jobs,
|
||||||
|
build_ffmpeg_command, build_audio_extract_command, detect_hw_encoders,
|
||||||
|
)
|
||||||
|
from core.db import ProcessedDB
|
||||||
|
from core.annotations import remove_clip_annotation, upsert_clip_annotation
|
||||||
|
from core.tracking import track_centers_for_jobs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Delete the extracted function definitions and dead imports**
|
||||||
|
|
||||||
|
Remove definitions from main.py:
|
||||||
|
- Lines 36-74: `_frozen_path`, `_bin`, `_log`, `build_export_path`, `build_sequence_dir`, `format_time`
|
||||||
|
- Lines 77-188: `resolve_keyframe`, `apply_keyframes_to_jobs`, `build_ffmpeg_command`, `build_audio_extract_command`
|
||||||
|
- Lines 191-241: annotation functions (`build_annotation_json_path`, `remove_clip_annotation`, `upsert_clip_annotation`)
|
||||||
|
- Lines 244-289: `detect_hw_encoders`, `_RATIOS`, `_portrait_crop_filter`
|
||||||
|
- Lines 294-395: tracking functions (`_yolo_model`, `_get_yolo`, `extract_frame_cv`, `detect_subject_center`, `track_centers_for_jobs`)
|
||||||
|
- Lines 398-626: `ProcessedDB` class
|
||||||
|
|
||||||
|
Remove now-dead stdlib imports from the top of main.py:
|
||||||
|
- `re` (only used in `detect_hw_encoders`)
|
||||||
|
- `json` (only used in annotation functions)
|
||||||
|
- `sqlite3` (only used in `ProcessedDB`)
|
||||||
|
- `tempfile` (only used in `extract_frame_cv`)
|
||||||
|
- `datetime`, `timezone` from the datetime import (only used in `_log` and `ProcessedDB`)
|
||||||
|
|
||||||
|
Keep in main.py:
|
||||||
|
- `_SELVA_CATEGORIES` (UI constant, line 291)
|
||||||
|
- `_RATIOS` reference — imported from core.ffmpeg
|
||||||
|
- `ExportWorker` (QThread-based, stays in main.py — the server uses `core.export.ExportRunner` instead)
|
||||||
|
- `_DBWorker` and `FrameGrabber` (QThread-based, stay in main.py)
|
||||||
|
|
||||||
|
**Step 3: Verify Qt app still works**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open a video, export a clip, check markers — verify nothing broke.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add main.py
|
||||||
|
git commit -m "refactor: import shared logic from core/ instead of inline definitions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Create server/config.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/__init__.py` (empty package marker)
|
||||||
|
- Create: `server/config.py`
|
||||||
|
|
||||||
|
**Step 1: Create `server/__init__.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# empty — package marker
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create config**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MEDIA_DIRS: list[str] = [
|
||||||
|
d.strip() for d in os.environ.get("MEDIA_DIRS", str(Path.home())).split(",") if d.strip()
|
||||||
|
]
|
||||||
|
EXPORT_DIR: str = os.environ.get("EXPORT_DIR", str(Path.home() / "8cut-exports"))
|
||||||
|
DB_PATH: str = os.environ.get("DB_PATH", str(Path.home() / ".8cut.db"))
|
||||||
|
CACHE_DIR: str = os.environ.get("CACHE_DIR", str(Path.home() / ".8cut-cache"))
|
||||||
|
HOST: str = os.environ.get("HOST", "0.0.0.0")
|
||||||
|
PORT: int = int(os.environ.get("PORT", "8000"))
|
||||||
|
|
||||||
|
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".ts", ".flv", ".wmv"}
|
||||||
|
|
||||||
|
QUALITY_PRESETS = {
|
||||||
|
"potato": {"height": 480, "bitrate": "500k"},
|
||||||
|
"low": {"height": 720, "bitrate": "2M"},
|
||||||
|
"medium": {"height": 1080, "bitrate": "5M"},
|
||||||
|
"high": {"height": 0, "bitrate": "10M"}, # 0 = original resolution
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/
|
||||||
|
git commit -m "feat: create server/config with env var settings and quality presets"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Create server/app.py — FastAPI skeleton + file listing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/app.py`
|
||||||
|
- Create: `server/routes/__init__.py`
|
||||||
|
- Create: `server/routes/files.py`
|
||||||
|
|
||||||
|
**Step 1: Create FastAPI app**
|
||||||
|
|
||||||
|
`server/app.py`:
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from .routes import files, stream, markers, export, hidden
|
||||||
|
|
||||||
|
app = FastAPI(title="8-cut Server")
|
||||||
|
app.include_router(files.router, prefix="/api")
|
||||||
|
app.include_router(stream.router, prefix="/api")
|
||||||
|
app.include_router(markers.router, prefix="/api")
|
||||||
|
app.include_router(export.router, prefix="/api")
|
||||||
|
app.include_router(hidden.router, prefix="/api")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create file listing route**
|
||||||
|
|
||||||
|
`server/routes/files.py`:
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from ..config import MEDIA_DIRS, VIDEO_EXTENSIONS
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_videos(root: str) -> list[dict]:
|
||||||
|
results = []
|
||||||
|
for dirpath, _, filenames in os.walk(root):
|
||||||
|
for f in sorted(filenames):
|
||||||
|
if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS:
|
||||||
|
full = os.path.join(dirpath, f)
|
||||||
|
rel = os.path.relpath(full, root)
|
||||||
|
results.append({
|
||||||
|
"name": f,
|
||||||
|
"path": rel,
|
||||||
|
"root": root,
|
||||||
|
"size": os.path.getsize(full),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files")
|
||||||
|
def list_files(root: str | None = Query(None)):
|
||||||
|
dirs = [root] if root and root in MEDIA_DIRS else MEDIA_DIRS
|
||||||
|
files = []
|
||||||
|
for d in dirs:
|
||||||
|
files.extend(_scan_videos(d))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roots")
|
||||||
|
def list_roots():
|
||||||
|
return MEDIA_DIRS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Create `server/routes/__init__.py`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# empty — package marker
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Create stub routers** so app.py imports don't fail. Each file gets a minimal router — later tasks fill in the real endpoints.
|
||||||
|
|
||||||
|
`server/routes/stream.py`:
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter()
|
||||||
|
```
|
||||||
|
|
||||||
|
`server/routes/markers.py`:
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter()
|
||||||
|
```
|
||||||
|
|
||||||
|
`server/routes/export.py`:
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter()
|
||||||
|
```
|
||||||
|
|
||||||
|
`server/routes/hidden.py`:
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/
|
||||||
|
git commit -m "feat: add FastAPI app with file listing endpoint"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Create server/routes/stream.py — video serving + transcode cache
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/cache.py`
|
||||||
|
- Create: `server/routes/stream.py`
|
||||||
|
|
||||||
|
**Step 1: Create cache manager**
|
||||||
|
|
||||||
|
`server/cache.py` handles:
|
||||||
|
- Computing cache paths from source file hash + quality
|
||||||
|
- Checking cache status
|
||||||
|
- Launching background ffmpeg transcodes
|
||||||
|
- Tracking in-progress jobs
|
||||||
|
|
||||||
|
**Step 2: Create stream routes**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/video/{path} — raw file, range requests
|
||||||
|
GET /api/stream/{path}?quality=low — cached transcode, range requests (202 if not ready)
|
||||||
|
GET /api/audio/{path} — cached audio extraction, range requests (202 if not ready)
|
||||||
|
GET /api/cache/status/{path} — cache status for all qualities
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/cache.py server/routes/stream.py
|
||||||
|
git commit -m "feat: add video streaming with transcode cache and audio extraction"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Create server/routes/markers.py — DB endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/routes/markers.py`
|
||||||
|
|
||||||
|
**Step 1: Create markers/profiles/labels routes**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/markers/{filename}?profile=default
|
||||||
|
GET /api/profiles
|
||||||
|
GET /api/labels
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `ProcessedDB` singleton from `core.db`.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/routes/markers.py
|
||||||
|
git commit -m "feat: add markers, profiles, and labels API endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Create server/routes/export.py + WebSocket
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/routes/export.py`
|
||||||
|
- Create: `server/ws.py`
|
||||||
|
|
||||||
|
**Step 1: Create export routes + WS**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/export — start export job
|
||||||
|
GET /api/export/{id} — check job status
|
||||||
|
DELETE /api/export/{path} — delete export from DB + disk
|
||||||
|
WS /ws/export — real-time progress
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `ExportRunner` from `core.export`.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/routes/export.py server/ws.py
|
||||||
|
git commit -m "feat: add export endpoint with WebSocket progress"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 13: Create server/routes/hidden.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/routes/hidden.py`
|
||||||
|
|
||||||
|
**Step 1: Create hidden file routes**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/hidden/{filename}?profile=default
|
||||||
|
DELETE /api/hidden/{filename}?profile=default
|
||||||
|
GET /api/hidden?profile=default
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/routes/hidden.py
|
||||||
|
git commit -m "feat: add hidden files API endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 14: Create Dockerfile + docker-compose.yml
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Dockerfile`
|
||||||
|
- Create: `docker-compose.yml`
|
||||||
|
|
||||||
|
**Step 1: Create Dockerfile**
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.12-slim
|
||||||
|
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY core/ core/
|
||||||
|
COPY server/ server/
|
||||||
|
# Note: ultralytics + opencv-python needed only if subject tracking is used.
|
||||||
|
# Add them here if tracking is required on the server.
|
||||||
|
RUN pip install --no-cache-dir fastapi uvicorn
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create docker-compose.yml**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
8cut:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- /path/to/videos:/videos:ro
|
||||||
|
- /path/to/exports:/exports
|
||||||
|
- 8cut-data:/data
|
||||||
|
environment:
|
||||||
|
MEDIA_DIRS: /videos
|
||||||
|
EXPORT_DIR: /exports
|
||||||
|
DB_PATH: /data/8cut.db
|
||||||
|
CACHE_DIR: /data/cache
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
8cut-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Dockerfile docker-compose.yml
|
||||||
|
git commit -m "feat: add Dockerfile and docker-compose for server deployment"
|
||||||
|
```
|
||||||
@@ -4,15 +4,10 @@ locale.setlocale(locale.LC_NUMERIC, "C") # required by libmpv before any import
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import json
|
|
||||||
import random
|
import random
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
@@ -32,599 +27,18 @@ elif sys.platform == "darwin" and getattr(sys, "frozen", False):
|
|||||||
os.environ.setdefault("DYLD_LIBRARY_PATH", str(Path(sys._MEIPASS)))
|
os.environ.setdefault("DYLD_LIBRARY_PATH", str(Path(sys._MEIPASS)))
|
||||||
import mpv
|
import mpv
|
||||||
|
|
||||||
|
from core.paths import _bin, _log, build_export_path, build_sequence_dir, format_time
|
||||||
def _frozen_path() -> Path:
|
from core.ffmpeg import (
|
||||||
"""Return the directory containing bundled binaries in a PyInstaller build."""
|
_RATIOS, resolve_keyframe, apply_keyframes_to_jobs,
|
||||||
if getattr(sys, "frozen", False):
|
build_ffmpeg_command, build_audio_extract_command, detect_hw_encoders,
|
||||||
return Path(sys._MEIPASS)
|
|
||||||
return Path(__file__).parent
|
|
||||||
|
|
||||||
|
|
||||||
def _bin(name: str) -> str:
|
|
||||||
"""Resolve a binary name (e.g. 'ffmpeg') to its full path in frozen builds."""
|
|
||||||
p = _frozen_path() / name
|
|
||||||
if p.exists():
|
|
||||||
return str(p)
|
|
||||||
return name # fall back to PATH
|
|
||||||
|
|
||||||
|
|
||||||
def _log(*args) -> None:
|
|
||||||
"""Print a timestamped log line to stderr."""
|
|
||||||
ts = datetime.now().strftime("%H:%M:%S")
|
|
||||||
print(f"[8-cut {ts}]", *args, file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def build_export_path(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
|
||||||
group = f"{basename}_{counter:03d}"
|
|
||||||
name = f"{group}_{sub}" if sub is not None else group
|
|
||||||
return os.path.join(folder, group, name + ".mp4")
|
|
||||||
|
|
||||||
|
|
||||||
def build_sequence_dir(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
|
||||||
group = f"{basename}_{counter:03d}"
|
|
||||||
name = f"{group}_{sub}" if sub is not None else group
|
|
||||||
return os.path.join(folder, group, name)
|
|
||||||
|
|
||||||
|
|
||||||
def format_time(seconds: float) -> str:
|
|
||||||
m = int(seconds // 60)
|
|
||||||
# Floor-truncate to 1 dp (not round) — prevents "X:60.0" rollover when
|
|
||||||
# seconds is e.g. 59.95. This means display may lag true position by up to 0.1s.
|
|
||||||
s = int(seconds % 60 * 10) / 10
|
|
||||||
return f"{m}:{s:04.1f}"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_keyframe(
|
|
||||||
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
|
||||||
t: float,
|
|
||||||
tolerance: float = 0.05,
|
|
||||||
) -> tuple[float, float, str | None, bool, bool] | None:
|
|
||||||
"""Return the latest keyframe at or before *t*, or None."""
|
|
||||||
result = None
|
|
||||||
for kf in keyframes:
|
|
||||||
if kf[0] <= t + tolerance:
|
|
||||||
result = kf
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def apply_keyframes_to_jobs(
|
|
||||||
jobs: list[tuple[float, str, str | None, float]],
|
|
||||||
keyframes: list[tuple[float, float, str | None, bool, bool]],
|
|
||||||
base_center: float,
|
|
||||||
base_ratio: str | None,
|
|
||||||
base_rand_p: bool,
|
|
||||||
base_rand_s: bool,
|
|
||||||
) -> list[tuple[float, str, str | None, float, bool, bool]]:
|
|
||||||
"""Resolve each job's crop state from keyframes, returning widened tuples.
|
|
||||||
|
|
||||||
Returns list of (start, path, ratio, center, rand_portrait, rand_square).
|
|
||||||
"""
|
|
||||||
result = []
|
|
||||||
for s, o, _r, _c in jobs:
|
|
||||||
kf = resolve_keyframe(keyframes, s)
|
|
||||||
if kf is not None:
|
|
||||||
_, center, ratio, rp, rs = kf
|
|
||||||
else:
|
|
||||||
center, ratio, rp, rs = base_center, base_ratio, base_rand_p, base_rand_s
|
|
||||||
result.append((s, o, ratio, center, rp, rs))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def build_ffmpeg_command(
|
|
||||||
input_path: str, start: float, output_path: str,
|
|
||||||
short_side: int | None = None,
|
|
||||||
portrait_ratio: str | None = None,
|
|
||||||
crop_center: float = 0.5,
|
|
||||||
image_sequence: bool = False,
|
|
||||||
encoder: str = "libx264",
|
|
||||||
) -> list[str]:
|
|
||||||
# -ss before -i: fast input-seeking. Safe here because we always re-encode,
|
|
||||||
# so there is no keyframe-alignment issue from pre-input seek.
|
|
||||||
# Image sequences always use libwebp, so skip HW encoder setup.
|
|
||||||
use_hw_vaapi = encoder == "h264_vaapi" and not image_sequence
|
|
||||||
cmd = [_bin("ffmpeg"), "-y"]
|
|
||||||
|
|
||||||
# VAAPI needs a device for hardware context.
|
|
||||||
if use_hw_vaapi:
|
|
||||||
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
|
|
||||||
"-vaapi_device", "/dev/dri/renderD128"]
|
|
||||||
|
|
||||||
cmd += [
|
|
||||||
"-threads", "0",
|
|
||||||
"-ss", str(start),
|
|
||||||
"-i", input_path,
|
|
||||||
"-t", "8",
|
|
||||||
]
|
|
||||||
|
|
||||||
filters: list[str] = []
|
|
||||||
if portrait_ratio is not None:
|
|
||||||
filters.append(_portrait_crop_filter(portrait_ratio, crop_center))
|
|
||||||
if short_side is not None:
|
|
||||||
# Scale so the shorter dimension equals short_side.
|
|
||||||
# if(lt(iw,ih),...) → portrait output: fix width; landscape: fix height.
|
|
||||||
# -2 keeps aspect ratio with even-pixel rounding (encoder requirement).
|
|
||||||
filters.append(
|
|
||||||
f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})':flags=lanczos"
|
|
||||||
)
|
)
|
||||||
|
from core.db import ProcessedDB
|
||||||
# VAAPI: decoded frames are GPU surfaces. CPU filters (crop/scale) need
|
from core.annotations import remove_clip_annotation, upsert_clip_annotation
|
||||||
# hwdownload first, then re-upload for the HW encoder.
|
from core.tracking import track_centers_for_jobs
|
||||||
if use_hw_vaapi:
|
|
||||||
if filters:
|
|
||||||
filters.insert(0, "hwdownload")
|
|
||||||
filters.insert(1, "format=nv12")
|
|
||||||
filters.append("format=nv12")
|
|
||||||
filters.append("hwupload")
|
|
||||||
|
|
||||||
if filters:
|
|
||||||
cmd += ["-vf", ",".join(filters)]
|
|
||||||
|
|
||||||
if image_sequence:
|
|
||||||
cmd += [
|
|
||||||
"-an",
|
|
||||||
"-c:v", "libwebp",
|
|
||||||
"-quality", "92",
|
|
||||||
"-compression_level", "1",
|
|
||||||
os.path.join(output_path, "frame_%04d.webp"),
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
cmd += ["-c:v", encoder, "-c:a", "pcm_s16le", output_path]
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
|
|
||||||
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str) -> list[str]:
|
|
||||||
"""Return an ffmpeg command that extracts audio to <sequence_dir>.wav."""
|
|
||||||
audio_path = sequence_dir + ".wav"
|
|
||||||
return [
|
|
||||||
_bin("ffmpeg"), "-y",
|
|
||||||
"-ss", str(start),
|
|
||||||
"-i", input_path,
|
|
||||||
"-t", "8",
|
|
||||||
"-vn",
|
|
||||||
"-c:a", "pcm_s16le",
|
|
||||||
audio_path,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_annotation_json_path(folder: str) -> str:
|
|
||||||
return os.path.join(folder, "dataset.json")
|
|
||||||
|
|
||||||
|
|
||||||
def remove_clip_annotation(folder: str, clip_path: str) -> None:
|
|
||||||
"""Remove the entry for *clip_path* from <folder>/dataset.json if present."""
|
|
||||||
json_path = build_annotation_json_path(folder)
|
|
||||||
if not os.path.exists(json_path):
|
|
||||||
return
|
|
||||||
abs_path = os.path.abspath(clip_path)
|
|
||||||
with open(json_path, "r", encoding="utf-8") as f:
|
|
||||||
try:
|
|
||||||
entries = json.load(f)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return
|
|
||||||
entries = [e for e in entries if e.get("path") != abs_path]
|
|
||||||
with open(json_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(entries, f, indent=2, ensure_ascii=False)
|
|
||||||
f.write("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_clip_annotation(folder: str, clip_path: str, label: str) -> None:
|
|
||||||
"""Insert or update one entry in <folder>/dataset.json.
|
|
||||||
|
|
||||||
Each entry stores a path relative to *folder* and the sound label.
|
|
||||||
Matches on ``path``; if an entry for the same clip already exists it is
|
|
||||||
replaced (overwrite-export case). Nothing is written when *label* is
|
|
||||||
empty.
|
|
||||||
"""
|
|
||||||
if not label.strip():
|
|
||||||
return
|
|
||||||
os.makedirs(folder, exist_ok=True)
|
|
||||||
json_path = build_annotation_json_path(folder)
|
|
||||||
entries: list[dict] = []
|
|
||||||
if os.path.exists(json_path):
|
|
||||||
with open(json_path, "r", encoding="utf-8") as f:
|
|
||||||
try:
|
|
||||||
entries = json.load(f)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
entries = []
|
|
||||||
abs_path = os.path.abspath(clip_path)
|
|
||||||
entry: dict = {"path": abs_path, "label": label}
|
|
||||||
for i, e in enumerate(entries):
|
|
||||||
if e.get("path") == abs_path:
|
|
||||||
entries[i] = entry
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
entries.append(entry)
|
|
||||||
with open(json_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(entries, f, indent=2, ensure_ascii=False)
|
|
||||||
f.write("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def detect_hw_encoders() -> list[str]:
|
|
||||||
"""Probe ffmpeg for available H.264 hardware encoders.
|
|
||||||
|
|
||||||
Returns a list like ["h264_nvenc", "h264_vaapi", ...].
|
|
||||||
Only includes encoders that ffmpeg reports as available.
|
|
||||||
"""
|
|
||||||
_HW_ENCODERS = ["h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox"]
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_bin("ffmpeg"), "-hide_banner", "-encoders"],
|
|
||||||
capture_output=True, text=True, timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
output = result.stdout
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
available = []
|
|
||||||
for enc in _HW_ENCODERS:
|
|
||||||
if re.search(rf'\b{enc}\b', output):
|
|
||||||
available.append(enc)
|
|
||||||
if available:
|
|
||||||
_log(f"HW encoders detected: {', '.join(available)}")
|
|
||||||
else:
|
|
||||||
_log("No HW encoders detected — GPU export unavailable")
|
|
||||||
return available
|
|
||||||
|
|
||||||
|
|
||||||
_RATIOS: dict[str, tuple[int, int]] = {
|
|
||||||
"9:16": (9, 16),
|
|
||||||
"4:5": (4, 5),
|
|
||||||
"1:1": (1, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _portrait_crop_filter(ratio: str, crop_center: float) -> str:
|
|
||||||
"""Return an ffmpeg crop= filter expression for the given portrait ratio.
|
|
||||||
|
|
||||||
Uses ffmpeg expression syntax so source dimensions are resolved at runtime.
|
|
||||||
Commas inside min()/max() are escaped with \\, to prevent ffmpeg's
|
|
||||||
filtergraph parser from treating them as filter-chain separators.
|
|
||||||
"""
|
|
||||||
num, den = _RATIOS[ratio]
|
|
||||||
cw = f"ih*{num}/{den}"
|
|
||||||
x = f"max(0\\,min((iw-{cw})*{crop_center}\\,iw-{cw}))"
|
|
||||||
return f"crop={cw}:ih:{x}:0"
|
|
||||||
|
|
||||||
|
|
||||||
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
|
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Subject tracking (YOLO-based, optional)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_yolo_model = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_yolo():
|
|
||||||
"""Lazy-load YOLOv8-nano. Returns None if ultralytics is not installed."""
|
|
||||||
global _yolo_model
|
|
||||||
if _yolo_model is None:
|
|
||||||
try:
|
|
||||||
from ultralytics import YOLO
|
|
||||||
_yolo_model = YOLO("yolov8n.pt")
|
|
||||||
_log("YOLO model loaded")
|
|
||||||
except ImportError:
|
|
||||||
_log("ultralytics not installed — tracking disabled")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"YOLO load failed: {e}")
|
|
||||||
return None
|
|
||||||
return _yolo_model
|
|
||||||
|
|
||||||
|
|
||||||
def extract_frame_cv(video_path: str, time: float):
|
|
||||||
"""Extract a single frame as a numpy array (BGR) via ffmpeg → temp PNG → cv2."""
|
|
||||||
try:
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
except ImportError:
|
|
||||||
return None
|
|
||||||
fd, tmp = tempfile.mkstemp(suffix=".png")
|
|
||||||
os.close(fd)
|
|
||||||
try:
|
|
||||||
cmd = [_bin("ffmpeg"), "-y", "-ss", str(time), "-i", video_path,
|
|
||||||
"-frames:v", "1", tmp]
|
|
||||||
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return None
|
|
||||||
return cv2.imread(tmp)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
if os.path.exists(tmp):
|
|
||||||
os.unlink(tmp)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_subject_center(
|
|
||||||
video_path: str, time: float, target_cls: int | None, last_x: float, last_y: float,
|
|
||||||
) -> tuple[int | None, float, float] | None:
|
|
||||||
"""Detect objects at *time* and return (class_id, norm_x, norm_y) of the
|
|
||||||
best match to (target_cls, last_x, last_y). Returns None on failure."""
|
|
||||||
model = _get_yolo()
|
|
||||||
if model is None:
|
|
||||||
return None
|
|
||||||
frame = extract_frame_cv(video_path, time)
|
|
||||||
if frame is None:
|
|
||||||
return None
|
|
||||||
results = model(frame, verbose=False)
|
|
||||||
if not results or len(results[0].boxes) == 0:
|
|
||||||
return None
|
|
||||||
h, w = frame.shape[:2]
|
|
||||||
dets = []
|
|
||||||
for box in results[0].boxes:
|
|
||||||
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
|
||||||
cls = int(box.cls[0])
|
|
||||||
cx = (x1 + x2) / 2 / w
|
|
||||||
cy = (y1 + y2) / 2 / h
|
|
||||||
dets.append((cls, cx, cy))
|
|
||||||
# Prefer same class, nearest to last known position.
|
|
||||||
def score(d):
|
|
||||||
cls_penalty = 0 if (target_cls is None or d[0] == target_cls) else 1.0
|
|
||||||
dist = (d[1] - last_x) ** 2 + (d[2] - last_y) ** 2
|
|
||||||
return cls_penalty + dist
|
|
||||||
best = min(dets, key=score)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def track_centers_for_jobs(
|
|
||||||
video_path: str, cursor: float, crop_center: float,
|
|
||||||
starts: list[float],
|
|
||||||
) -> list[float]:
|
|
||||||
"""Run detection at the cursor (to identify the target) then at each start
|
|
||||||
time. Returns a list of horizontal crop centers (one per start)."""
|
|
||||||
ref = detect_subject_center(video_path, cursor, None, crop_center, 0.5)
|
|
||||||
if ref is None:
|
|
||||||
_log("Tracking: no detection at cursor, using fixed center")
|
|
||||||
return [crop_center] * len(starts)
|
|
||||||
target_cls, last_x, last_y = ref
|
|
||||||
_log(f"Tracking: target class={target_cls} at ({last_x:.2f}, {last_y:.2f})")
|
|
||||||
centers = []
|
|
||||||
for t in starts:
|
|
||||||
det = detect_subject_center(video_path, t, target_cls, last_x, last_y)
|
|
||||||
if det is not None:
|
|
||||||
_, cx, cy = det
|
|
||||||
_log(f" t={t:.2f}s → center={cx:.3f}")
|
|
||||||
centers.append(cx)
|
|
||||||
last_x, last_y = cx, cy
|
|
||||||
else:
|
|
||||||
_log(f" t={t:.2f}s → lost, reusing {last_x:.3f}")
|
|
||||||
centers.append(last_x)
|
|
||||||
return centers
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessedDB:
|
|
||||||
_SCHEMA_VERSION = 3 # bump when schema changes
|
|
||||||
|
|
||||||
def __init__(self, db_path: str | None = None):
|
|
||||||
if db_path is None:
|
|
||||||
db_path = str(Path.home() / ".8cut.db")
|
|
||||||
self._path = db_path
|
|
||||||
try:
|
|
||||||
self._con = sqlite3.connect(db_path, check_same_thread=False)
|
|
||||||
self._migrate()
|
|
||||||
self._enabled = True
|
|
||||||
_log(f"DB opened: {db_path}")
|
|
||||||
except Exception as e:
|
|
||||||
_log(f"DB unavailable: {e}")
|
|
||||||
self._con = None
|
|
||||||
self._enabled = False
|
|
||||||
|
|
||||||
def _migrate(self) -> None:
|
|
||||||
"""Create table if missing, then add any new columns for old DBs."""
|
|
||||||
cols = {
|
|
||||||
row[1]
|
|
||||||
for row in self._con.execute("PRAGMA table_info(processed)").fetchall()
|
|
||||||
}
|
|
||||||
if not cols:
|
|
||||||
# Fresh DB — create from scratch
|
|
||||||
self._con.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS processed ("
|
|
||||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
||||||
" filename TEXT NOT NULL,"
|
|
||||||
" start_time REAL NOT NULL,"
|
|
||||||
" output_path TEXT NOT NULL,"
|
|
||||||
" label TEXT NOT NULL DEFAULT '',"
|
|
||||||
" category TEXT NOT NULL DEFAULT '',"
|
|
||||||
" short_side INTEGER DEFAULT 512,"
|
|
||||||
" portrait_ratio TEXT NOT NULL DEFAULT '',"
|
|
||||||
" crop_center REAL NOT NULL DEFAULT 0.5,"
|
|
||||||
" format TEXT NOT NULL DEFAULT 'MP4',"
|
|
||||||
" clip_count INTEGER NOT NULL DEFAULT 3,"
|
|
||||||
" spread REAL NOT NULL DEFAULT 3.0,"
|
|
||||||
" profile TEXT NOT NULL DEFAULT 'default',"
|
|
||||||
" processed_at TEXT NOT NULL"
|
|
||||||
")"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Add missing columns to legacy tables
|
|
||||||
new_cols = {
|
|
||||||
"label": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"category": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"short_side": "INTEGER DEFAULT 512",
|
|
||||||
"portrait_ratio": "TEXT NOT NULL DEFAULT ''",
|
|
||||||
"crop_center": "REAL NOT NULL DEFAULT 0.5",
|
|
||||||
"format": "TEXT NOT NULL DEFAULT 'MP4'",
|
|
||||||
"clip_count": "INTEGER NOT NULL DEFAULT 3",
|
|
||||||
"spread": "REAL NOT NULL DEFAULT 3.0",
|
|
||||||
"profile": "TEXT NOT NULL DEFAULT 'default'",
|
|
||||||
}
|
|
||||||
for col, typedef in new_cols.items():
|
|
||||||
if col not in cols:
|
|
||||||
self._con.execute(
|
|
||||||
f"ALTER TABLE processed ADD COLUMN {col} {typedef}"
|
|
||||||
)
|
|
||||||
self._con.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
|
|
||||||
)
|
|
||||||
self._con.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS hidden_files ("
|
|
||||||
" filename TEXT NOT NULL,"
|
|
||||||
" profile TEXT NOT NULL DEFAULT 'default',"
|
|
||||||
" PRIMARY KEY (filename, profile)"
|
|
||||||
")"
|
|
||||||
)
|
|
||||||
self._con.commit()
|
|
||||||
|
|
||||||
def add(self, filename: str, start_time: float, output_path: str,
|
|
||||||
label: str = "", category: str = "",
|
|
||||||
short_side: int | None = None, portrait_ratio: str = "",
|
|
||||||
crop_center: float = 0.5, fmt: str = "MP4",
|
|
||||||
clip_count: int = 3, spread: float = 3.0,
|
|
||||||
profile: str = "default") -> None:
|
|
||||||
if not self._enabled:
|
|
||||||
return
|
|
||||||
self._con.execute(
|
|
||||||
"INSERT INTO processed"
|
|
||||||
" (filename, start_time, output_path, label, category,"
|
|
||||||
" short_side, portrait_ratio, crop_center, format,"
|
|
||||||
" clip_count, spread, profile, processed_at)"
|
|
||||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(filename, start_time, output_path, label, category,
|
|
||||||
short_side, portrait_ratio, crop_center, fmt,
|
|
||||||
clip_count, spread, profile,
|
|
||||||
datetime.now(timezone.utc).isoformat()),
|
|
||||||
)
|
|
||||||
self._con.commit()
|
|
||||||
|
|
||||||
def get_labels(self) -> list[str]:
|
|
||||||
"""Return distinct non-empty labels ordered by most recently used."""
|
|
||||||
if not self._enabled:
|
|
||||||
return []
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT DISTINCT label FROM processed"
|
|
||||||
" WHERE label != '' ORDER BY processed_at DESC"
|
|
||||||
).fetchall()
|
|
||||||
# Deduplicate while preserving order (DISTINCT on processed_at DESC
|
|
||||||
# may return duplicates if the same label was used multiple times).
|
|
||||||
seen: set[str] = set()
|
|
||||||
result = []
|
|
||||||
for (lbl,) in rows:
|
|
||||||
if lbl not in seen:
|
|
||||||
seen.add(lbl)
|
|
||||||
result.append(lbl)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_by_output_path(self, output_path: str) -> dict | None:
|
|
||||||
"""Return config dict for an output_path, or None."""
|
|
||||||
if not self._enabled:
|
|
||||||
return None
|
|
||||||
self._con.row_factory = sqlite3.Row
|
|
||||||
row = self._con.execute(
|
|
||||||
"SELECT label, category, short_side, portrait_ratio, crop_center, format,"
|
|
||||||
" clip_count, spread"
|
|
||||||
" FROM processed WHERE output_path = ?",
|
|
||||||
(output_path,),
|
|
||||||
).fetchone()
|
|
||||||
self._con.row_factory = None
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
def delete_by_output_path(self, output_path: str) -> None:
|
|
||||||
if not self._enabled:
|
|
||||||
return
|
|
||||||
self._con.execute("DELETE FROM processed WHERE output_path = ?", (output_path,))
|
|
||||||
self._con.commit()
|
|
||||||
|
|
||||||
def get_group(self, output_path: str) -> list[str]:
|
|
||||||
"""Return all output_paths sharing the same (filename, start_time) as *output_path*."""
|
|
||||||
if not self._enabled:
|
|
||||||
return []
|
|
||||||
row = self._con.execute(
|
|
||||||
"SELECT filename, start_time FROM processed WHERE output_path = ?",
|
|
||||||
(output_path,),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return []
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT output_path FROM processed"
|
|
||||||
" WHERE filename = ? AND start_time = ? ORDER BY output_path",
|
|
||||||
(row[0], row[1]),
|
|
||||||
).fetchall()
|
|
||||||
return [r[0] for r in rows]
|
|
||||||
|
|
||||||
def delete_group(self, output_path: str) -> list[str]:
|
|
||||||
"""Delete all rows sharing the same (filename, start_time) as *output_path*.
|
|
||||||
Returns list of deleted output_paths."""
|
|
||||||
if not self._enabled:
|
|
||||||
return []
|
|
||||||
row = self._con.execute(
|
|
||||||
"SELECT filename, start_time FROM processed WHERE output_path = ?",
|
|
||||||
(output_path,),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return []
|
|
||||||
filename, start_time = row
|
|
||||||
paths = [r[0] for r in self._con.execute(
|
|
||||||
"SELECT output_path FROM processed WHERE filename = ? AND start_time = ?",
|
|
||||||
(filename, start_time),
|
|
||||||
).fetchall()]
|
|
||||||
self._con.execute(
|
|
||||||
"DELETE FROM processed WHERE filename = ? AND start_time = ?",
|
|
||||||
(filename, start_time),
|
|
||||||
)
|
|
||||||
self._con.commit()
|
|
||||||
return paths
|
|
||||||
|
|
||||||
def _get_markers_for(self, match: str, profile: str = "default") -> list[tuple[float, int, str]]:
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT start_time, output_path FROM processed"
|
|
||||||
" WHERE filename = ? AND profile = ? ORDER BY start_time",
|
|
||||||
(match, profile),
|
|
||||||
).fetchall()
|
|
||||||
# Deduplicate by start_time — batch exports share the same cursor.
|
|
||||||
seen_times: dict[float, tuple[float, int, str]] = {}
|
|
||||||
n = 0
|
|
||||||
for t, p in rows:
|
|
||||||
if t not in seen_times:
|
|
||||||
n += 1
|
|
||||||
seen_times[t] = (t, n, p)
|
|
||||||
return list(seen_times.values())
|
|
||||||
|
|
||||||
def get_markers(self, filename: str, profile: str = "default") -> list[tuple[float, int, str]]:
|
|
||||||
"""Return [(start_time, marker_number, output_path), ...] for exact
|
|
||||||
filename match, sorted by start_time. Empty list if no match."""
|
|
||||||
if not self._enabled:
|
|
||||||
return []
|
|
||||||
return self._get_markers_for(filename, profile)
|
|
||||||
|
|
||||||
def get_profiles(self) -> list[str]:
|
|
||||||
"""Return distinct profile names, ordered alphabetically."""
|
|
||||||
if not self._enabled:
|
|
||||||
return []
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT DISTINCT profile FROM processed ORDER BY profile"
|
|
||||||
).fetchall()
|
|
||||||
return [r[0] for r in rows]
|
|
||||||
|
|
||||||
def hide_file(self, filename: str, profile: str = "default") -> None:
|
|
||||||
if not self._enabled:
|
|
||||||
return
|
|
||||||
self._con.execute(
|
|
||||||
"INSERT OR IGNORE INTO hidden_files (filename, profile) VALUES (?, ?)",
|
|
||||||
(filename, profile),
|
|
||||||
)
|
|
||||||
self._con.commit()
|
|
||||||
|
|
||||||
def unhide_file(self, filename: str, profile: str = "default") -> None:
|
|
||||||
if not self._enabled:
|
|
||||||
return
|
|
||||||
self._con.execute(
|
|
||||||
"DELETE FROM hidden_files WHERE filename = ? AND profile = ?",
|
|
||||||
(filename, profile),
|
|
||||||
)
|
|
||||||
self._con.commit()
|
|
||||||
|
|
||||||
def get_hidden_files(self, profile: str = "default") -> set[str]:
|
|
||||||
if not self._enabled:
|
|
||||||
return set()
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT filename FROM hidden_files WHERE profile = ?", (profile,)
|
|
||||||
).fetchall()
|
|
||||||
return {r[0] for r in rows}
|
|
||||||
|
|
||||||
|
|
||||||
class _DBWorker(QThread):
|
class _DBWorker(QThread):
|
||||||
"""Runs ProcessedDB fuzzy-match lookup off the main thread."""
|
"""Runs ProcessedDB fuzzy-match lookup off the main thread."""
|
||||||
result = pyqtSignal(str, object, list) # (queried_filename, match|None, markers)
|
result = pyqtSignal(str, object, list) # (queried_filename, match|None, markers)
|
||||||
@@ -839,6 +253,10 @@ class TimelineWidget(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_play_position(self, t: float | None) -> None:
|
def set_play_position(self, t: float | None) -> None:
|
||||||
|
# In lock mode, ignore mpv position updates while the user is dragging
|
||||||
|
# — the async seek hasn't caught up yet, so mpv reports stale values.
|
||||||
|
if self._locked and self._play_pos is not None and self._seek_timer.isActive():
|
||||||
|
return
|
||||||
self._play_pos = t
|
self._play_pos = t
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -1026,6 +444,7 @@ class TimelineWidget(QWidget):
|
|||||||
if abs(x - frac * w) <= 10:
|
if abs(x - frac * w) <= 10:
|
||||||
t = frac * self._duration
|
t = frac * self._duration
|
||||||
self.marker_clicked.emit(t, output_path)
|
self.marker_clicked.emit(t, output_path)
|
||||||
|
if not self._locked:
|
||||||
self._seek(x)
|
self._seek(x)
|
||||||
return
|
return
|
||||||
self.marker_deselected.emit()
|
self.marker_deselected.emit()
|
||||||
@@ -1332,12 +751,17 @@ class MpvWidget(QWidget):
|
|||||||
except SystemError:
|
except SystemError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def play_loop(self, a: float, b: float):
|
def play_loop(self, a: float, b: float, resume: bool = False):
|
||||||
self._player["ab-loop-a"] = a
|
self._player["ab-loop-a"] = a
|
||||||
self._player["ab-loop-b"] = min(b, self._player.duration or b)
|
self._player["ab-loop-b"] = min(b, self._player.duration or b)
|
||||||
|
if not resume:
|
||||||
self._player.seek(a, "absolute")
|
self._player.seek(a, "absolute")
|
||||||
self._player.pause = False
|
self._player.pause = False
|
||||||
|
|
||||||
|
def update_loop_end(self, b: float):
|
||||||
|
"""Adjust the B point of the current loop without seeking."""
|
||||||
|
self._player["ab-loop-b"] = min(b, self._player.duration or b)
|
||||||
|
|
||||||
def stop_loop(self):
|
def stop_loop(self):
|
||||||
self._player["ab-loop-a"] = "no"
|
self._player["ab-loop-a"] = "no"
|
||||||
self._player["ab-loop-b"] = "no"
|
self._player["ab-loop-b"] = "no"
|
||||||
@@ -1793,12 +1217,20 @@ class PlaylistWidget(QListWidget):
|
|||||||
|
|
||||||
|
|
||||||
class _KeyFilter(QObject):
|
class _KeyFilter(QObject):
|
||||||
"""Suppress global keyboard shortcuts when a text input widget has focus."""
|
"""Suppress global keyboard shortcuts when a text input widget has focus,
|
||||||
|
and release focus from input widgets on click-away."""
|
||||||
|
_INPUT_TYPES = (QSpinBox, QDoubleSpinBox, QLineEdit, QComboBox)
|
||||||
|
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
from PyQt6.QtCore import QEvent
|
from PyQt6.QtCore import QEvent
|
||||||
if event.type() == QEvent.Type.ShortcutOverride and isinstance(obj, QLineEdit):
|
if event.type() == QEvent.Type.ShortcutOverride and isinstance(obj, QLineEdit):
|
||||||
event.accept()
|
event.accept()
|
||||||
return True
|
return True
|
||||||
|
if event.type() == QEvent.Type.MouseButtonPress:
|
||||||
|
if not isinstance(obj, self._INPUT_TYPES):
|
||||||
|
focused = QApplication.focusWidget()
|
||||||
|
if isinstance(focused, self._INPUT_TYPES):
|
||||||
|
focused.clearFocus()
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
|
||||||
@@ -1867,6 +1299,15 @@ class MainWindow(QMainWindow):
|
|||||||
self._frame_grabber: FrameGrabber | None = None
|
self._frame_grabber: FrameGrabber | None = None
|
||||||
self._fps: float = 25.0 # cached on file load via get_fps()
|
self._fps: float = 25.0 # cached on file load via get_fps()
|
||||||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] # sorted by time
|
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] # sorted by time
|
||||||
|
self._export_folder: str = "" # actual folder used for current export (may include suffix)
|
||||||
|
self._export_folder_suffix: str = ""
|
||||||
|
|
||||||
|
# Subprofiles — lightweight export variants that append a suffix to the
|
||||||
|
# export folder. Stored in QSettings only (no DB impact).
|
||||||
|
_raw = self._settings.value("subprofiles", [])
|
||||||
|
if isinstance(_raw, str):
|
||||||
|
_raw = [_raw] if _raw else []
|
||||||
|
self._subprofiles: list[str] = _raw or []
|
||||||
|
|
||||||
# Widgets
|
# Widgets
|
||||||
self._playlist = PlaylistWidget()
|
self._playlist = PlaylistWidget()
|
||||||
@@ -2005,6 +1446,7 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self._spn_clips.valueChanged.connect(lambda: self._update_next_label())
|
self._spn_clips.valueChanged.connect(lambda: self._update_next_label())
|
||||||
self._spn_clips.valueChanged.connect(lambda: self._preview_timer.start())
|
self._spn_clips.valueChanged.connect(lambda: self._preview_timer.start())
|
||||||
|
self._spn_clips.valueChanged.connect(self._update_play_loop)
|
||||||
|
|
||||||
self._spn_spread = QDoubleSpinBox()
|
self._spn_spread = QDoubleSpinBox()
|
||||||
self._spn_spread.setRange(2.0, 8.0)
|
self._spn_spread.setRange(2.0, 8.0)
|
||||||
@@ -2020,6 +1462,7 @@ class MainWindow(QMainWindow):
|
|||||||
lambda: self._timeline.set_clip_span(self._clip_span)
|
lambda: self._timeline.set_clip_span(self._clip_span)
|
||||||
)
|
)
|
||||||
self._spn_spread.valueChanged.connect(lambda: self._preview_timer.start())
|
self._spn_spread.valueChanged.connect(lambda: self._preview_timer.start())
|
||||||
|
self._spn_spread.valueChanged.connect(self._update_play_loop)
|
||||||
|
|
||||||
self._chk_rand_portrait = QCheckBox("1 random portrait")
|
self._chk_rand_portrait = QCheckBox("1 random portrait")
|
||||||
self._chk_rand_portrait.setToolTip(
|
self._chk_rand_portrait.setToolTip(
|
||||||
@@ -2147,9 +1590,19 @@ class MainWindow(QMainWindow):
|
|||||||
transport_row.addStretch()
|
transport_row.addStretch()
|
||||||
transport_row.addWidget(self._lbl_next)
|
transport_row.addWidget(self._lbl_next)
|
||||||
transport_row.addWidget(self._btn_export)
|
transport_row.addWidget(self._btn_export)
|
||||||
|
# Subprofile export buttons sit right after Export
|
||||||
|
self._subprofile_btns: list[QPushButton] = []
|
||||||
|
self._sub_insert_anchor = self._btn_cancel # buttons inserted before this
|
||||||
|
self._btn_add_sub = QPushButton("+")
|
||||||
|
self._btn_add_sub.setFixedWidth(28)
|
||||||
|
self._btn_add_sub.setToolTip("Add a subprofile — exports to folder_suffix")
|
||||||
|
self._btn_add_sub.clicked.connect(self._add_subprofile)
|
||||||
|
transport_row.addWidget(self._btn_add_sub)
|
||||||
transport_row.addWidget(self._btn_cancel)
|
transport_row.addWidget(self._btn_cancel)
|
||||||
transport_row.addWidget(self._spn_workers)
|
transport_row.addWidget(self._spn_workers)
|
||||||
transport_row.addWidget(self._btn_delete)
|
transport_row.addWidget(self._btn_delete)
|
||||||
|
self._transport_row = transport_row
|
||||||
|
self._rebuild_subprofile_buttons()
|
||||||
|
|
||||||
# Row 2 — annotation + output path
|
# Row 2 — annotation + output path
|
||||||
path_row = QHBoxLayout()
|
path_row = QHBoxLayout()
|
||||||
@@ -2268,9 +1721,14 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
QShortcut(QKeySequence("K"), self, context=ctx).activated.connect(self._on_pause)
|
QShortcut(QKeySequence("K"), self, context=ctx).activated.connect(self._on_pause)
|
||||||
QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export)
|
QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export)
|
||||||
|
for i in range(1, 10):
|
||||||
|
QShortcut(QKeySequence(str(i)), self, context=ctx).activated.connect(
|
||||||
|
lambda _, idx=i - 1: self._export_subprofile(idx)
|
||||||
|
)
|
||||||
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
|
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
|
||||||
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
||||||
QShortcut(QKeySequence("G"), self, context=ctx).activated.connect(self._btn_lock.toggle)
|
QShortcut(QKeySequence("G"), self, context=ctx).activated.connect(self._btn_lock.toggle)
|
||||||
|
QShortcut(QKeySequence("A"), self, context=ctx).activated.connect(self._autoclip)
|
||||||
for key in ("?", "F1"):
|
for key in ("?", "F1"):
|
||||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
||||||
|
|
||||||
@@ -2295,12 +1753,14 @@ class MainWindow(QMainWindow):
|
|||||||
"<tr><td><b>Space / P</b></td><td>Play / Pause</td></tr>"
|
"<tr><td><b>Space / P</b></td><td>Play / Pause</td></tr>"
|
||||||
"<tr><td><b>K</b></td><td>Pause and snap to cursor</td></tr>"
|
"<tr><td><b>K</b></td><td>Pause and snap to cursor</td></tr>"
|
||||||
"<tr><td><b>E</b></td><td>Export</td></tr>"
|
"<tr><td><b>E</b></td><td>Export</td></tr>"
|
||||||
|
"<tr><td><b>1–9</b></td><td>Export to subprofile 1–9</td></tr>"
|
||||||
"<tr><td><b>M</b></td><td>Jump to next marker</td></tr>"
|
"<tr><td><b>M</b></td><td>Jump to next marker</td></tr>"
|
||||||
"<tr><td><b>N</b></td><td>Next file in playlist</td></tr>"
|
"<tr><td><b>N</b></td><td>Next file in playlist</td></tr>"
|
||||||
"<tr><td><b>G</b></td><td>Toggle cursor lock</td></tr>"
|
"<tr><td><b>G</b></td><td>Toggle cursor lock</td></tr>"
|
||||||
|
"<tr><td><b>A</b></td><td>Autoclip — fit clip count to pause position</td></tr>"
|
||||||
"<tr><td><b>? / F1</b></td><td>This help</td></tr>"
|
"<tr><td><b>? / F1</b></td><td>This help</td></tr>"
|
||||||
"<tr><td colspan='2'><hr></td></tr>"
|
"<tr><td colspan='2'><hr></td></tr>"
|
||||||
"<tr><td><b>Double-click marker</b></td><td>Enter overwrite mode</td></tr>"
|
"<tr><td><b>Double-click marker</b></td><td>Enter overwrite mode (locked: jump to end of clip span)</td></tr>"
|
||||||
"<tr><td><b>Right-click marker</b></td><td>Delete clip group</td></tr>"
|
"<tr><td><b>Right-click marker</b></td><td>Delete clip group</td></tr>"
|
||||||
"<tr><td><b>Click video / crop bar</b></td><td>Reposition portrait crop</td></tr>"
|
"<tr><td><b>Click video / crop bar</b></td><td>Reposition portrait crop</td></tr>"
|
||||||
"</table>"
|
"</table>"
|
||||||
@@ -2367,6 +1827,58 @@ class MainWindow(QMainWindow):
|
|||||||
_log(f"Profile switched: {text}")
|
_log(f"Profile switched: {text}")
|
||||||
self._show_status(f"Profile: {text}", 3000)
|
self._show_status(f"Profile: {text}", 3000)
|
||||||
|
|
||||||
|
# ── Subprofiles ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _rebuild_subprofile_buttons(self):
|
||||||
|
"""Recreate the per-subprofile export buttons in the transport row."""
|
||||||
|
for btn in self._subprofile_btns:
|
||||||
|
self._transport_row.removeWidget(btn)
|
||||||
|
btn.deleteLater()
|
||||||
|
self._subprofile_btns.clear()
|
||||||
|
# Find where to insert: right after the main Export button.
|
||||||
|
anchor = self._transport_row.indexOf(self._btn_add_sub)
|
||||||
|
has_file = bool(self._file_path)
|
||||||
|
for i, name in enumerate(self._subprofiles):
|
||||||
|
btn = QPushButton(f"▸ {name}")
|
||||||
|
btn.setToolTip(f"Export to folder_{name} (right-click to remove)")
|
||||||
|
btn.setEnabled(has_file)
|
||||||
|
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
|
||||||
|
self._transport_row.insertWidget(anchor + i, btn)
|
||||||
|
self._subprofile_btns.append(btn)
|
||||||
|
|
||||||
|
def _add_subprofile(self):
|
||||||
|
from PyQt6.QtWidgets import QMenu
|
||||||
|
menu = QMenu(self)
|
||||||
|
for name in self._subprofiles:
|
||||||
|
menu.addAction(f"Remove '{name}'", lambda n=name: self._remove_subprofile(n))
|
||||||
|
if self._subprofiles:
|
||||||
|
menu.addSeparator()
|
||||||
|
menu.addAction("Add new…", self._new_subprofile)
|
||||||
|
menu.exec(self._btn_add_sub.mapToGlobal(self._btn_add_sub.rect().bottomLeft()))
|
||||||
|
|
||||||
|
def _new_subprofile(self):
|
||||||
|
name, ok = QInputDialog.getText(self, "New subprofile", "Suffix name:")
|
||||||
|
if ok and name.strip():
|
||||||
|
name = name.strip().replace(" ", "_")
|
||||||
|
if name not in self._subprofiles:
|
||||||
|
self._subprofiles.append(name)
|
||||||
|
self._settings.setValue("subprofiles", self._subprofiles)
|
||||||
|
self._rebuild_subprofile_buttons()
|
||||||
|
|
||||||
|
def _export_subprofile(self, idx: int):
|
||||||
|
if idx < len(self._subprofiles):
|
||||||
|
self._on_export(folder_suffix=self._subprofiles[idx])
|
||||||
|
|
||||||
|
def _remove_subprofile(self, name: str):
|
||||||
|
if name in self._subprofiles:
|
||||||
|
self._subprofiles.remove(name)
|
||||||
|
self._settings.setValue("subprofiles", self._subprofiles)
|
||||||
|
self._rebuild_subprofile_buttons()
|
||||||
|
|
||||||
|
def _set_subprofile_btns_enabled(self, enabled: bool):
|
||||||
|
for btn in self._subprofile_btns:
|
||||||
|
btn.setEnabled(enabled)
|
||||||
|
|
||||||
def _show_status(self, msg: str, timeout: int = 0) -> None:
|
def _show_status(self, msg: str, timeout: int = 0) -> None:
|
||||||
"""Show a message in the inline status label. Timeout in ms (0 = sticky)."""
|
"""Show a message in the inline status label. Timeout in ms (0 = sticky)."""
|
||||||
self._lbl_status.setText(msg)
|
self._lbl_status.setText(msg)
|
||||||
@@ -2437,6 +1949,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_play.setEnabled(True)
|
self._btn_play.setEnabled(True)
|
||||||
self._btn_pause.setEnabled(True)
|
self._btn_pause.setEnabled(True)
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
|
self._set_subprofile_btns_enabled(True)
|
||||||
# Reset stale state from previous file
|
# Reset stale state from previous file
|
||||||
self._overwrite_path = ""
|
self._overwrite_path = ""
|
||||||
self._overwrite_group = []
|
self._overwrite_group = []
|
||||||
@@ -2511,6 +2024,20 @@ class MainWindow(QMainWindow):
|
|||||||
self._show_status(f"Deleted keyframe @ {format_time(time)}", 3000)
|
self._show_status(f"Deleted keyframe @ {format_time(time)}", 3000)
|
||||||
|
|
||||||
def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
|
def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
|
||||||
|
# In lock mode, move cursor to the end of this marker's span.
|
||||||
|
if self._btn_lock.isChecked():
|
||||||
|
meta = self._db.get_by_output_path(output_path)
|
||||||
|
clip_count = meta["clip_count"] or self._spn_clips.value() if meta else self._spn_clips.value()
|
||||||
|
spread = meta["spread"] or self._spn_spread.value() if meta else self._spn_spread.value()
|
||||||
|
next_pos = start_time + 8.0 + (clip_count - 1) * spread
|
||||||
|
self._cursor = next_pos
|
||||||
|
self._timeline.set_cursor(next_pos)
|
||||||
|
self._mpv.seek(next_pos)
|
||||||
|
self._lbl_time.setText(f"{format_time(next_pos)} / {format_time(self._mpv.get_duration())}")
|
||||||
|
self._update_next_label()
|
||||||
|
self._preview_timer.start()
|
||||||
|
self._show_status(f"Cursor → end of {os.path.basename(os.path.dirname(output_path))}", 3000)
|
||||||
|
return
|
||||||
self._overwrite_path = output_path
|
self._overwrite_path = output_path
|
||||||
self._overwrite_group = self._db.get_group(output_path)
|
self._overwrite_group = self._db.get_group(output_path)
|
||||||
n = len(self._overwrite_group)
|
n = len(self._overwrite_group)
|
||||||
@@ -2826,22 +2353,38 @@ class MainWindow(QMainWindow):
|
|||||||
if self._mpv.is_playing():
|
if self._mpv.is_playing():
|
||||||
self._on_pause()
|
self._on_pause()
|
||||||
else:
|
else:
|
||||||
self._on_play()
|
self._on_play(resume=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _clip_span(self) -> float:
|
def _clip_span(self) -> float:
|
||||||
"""Total time covered by the overlapping clips."""
|
"""Total time covered by the overlapping clips."""
|
||||||
return 8.0 + (self._spn_clips.value() - 1) * self._spn_spread.value()
|
return 8.0 + (self._spn_clips.value() - 1) * self._spn_spread.value()
|
||||||
|
|
||||||
def _on_play(self):
|
def _on_play(self, resume: bool = False):
|
||||||
if not self._file_path:
|
if not self._file_path:
|
||||||
return
|
return
|
||||||
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span)
|
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span, resume=resume)
|
||||||
|
|
||||||
|
def _update_play_loop(self):
|
||||||
|
if self._file_path and self._mpv.is_playing():
|
||||||
|
self._mpv.update_loop_end(self._cursor + self._clip_span)
|
||||||
|
|
||||||
def _on_pause(self):
|
def _on_pause(self):
|
||||||
self._mpv.stop_loop()
|
self._mpv.stop_loop()
|
||||||
self._mpv.seek(self._cursor)
|
|
||||||
self._timeline.set_play_position(None)
|
def _autoclip(self):
|
||||||
|
"""Set clip count to fit the current pause position."""
|
||||||
|
if not self._file_path:
|
||||||
|
return
|
||||||
|
play_t = self._timeline._play_pos
|
||||||
|
if play_t is None or play_t <= self._cursor:
|
||||||
|
return
|
||||||
|
elapsed = play_t - self._cursor
|
||||||
|
spread = self._spn_spread.value()
|
||||||
|
# n clips span 8 + (n-1)*spread seconds
|
||||||
|
n = int((elapsed - 8.0) / spread) + 1
|
||||||
|
n = max(1, n)
|
||||||
|
self._spn_clips.setValue(n)
|
||||||
|
|
||||||
def _step_cursor(self, delta: float) -> None:
|
def _step_cursor(self, delta: float) -> None:
|
||||||
if not self._file_path:
|
if not self._file_path:
|
||||||
@@ -2897,7 +2440,7 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
self._lbl_next.setText(f"→ {base}_0..{n - 1}")
|
self._lbl_next.setText(f"→ {base}_0..{n - 1}")
|
||||||
|
|
||||||
def _on_export(self):
|
def _on_export(self, _=None, folder_suffix: str = ""):
|
||||||
if not self._file_path:
|
if not self._file_path:
|
||||||
return
|
return
|
||||||
if self._export_worker and self._export_worker.isRunning():
|
if self._export_worker and self._export_worker.isRunning():
|
||||||
@@ -2907,12 +2450,15 @@ class MainWindow(QMainWindow):
|
|||||||
fmt = self._cmb_format.currentText()
|
fmt = self._cmb_format.currentText()
|
||||||
image_sequence = fmt == "WebP sequence"
|
image_sequence = fmt == "WebP sequence"
|
||||||
folder = self._txt_folder.text()
|
folder = self._txt_folder.text()
|
||||||
|
if folder_suffix:
|
||||||
|
folder = folder.rstrip(os.sep) + "_" + folder_suffix
|
||||||
os.makedirs(folder, exist_ok=True)
|
os.makedirs(folder, exist_ok=True)
|
||||||
spread = self._spn_spread.value()
|
spread = self._spn_spread.value()
|
||||||
|
|
||||||
ratio_text = self._cmb_portrait.currentText()
|
ratio_text = self._cmb_portrait.currentText()
|
||||||
base_ratio = None if ratio_text == "Off" else ratio_text
|
base_ratio = None if ratio_text == "Off" else ratio_text
|
||||||
base_center = self._crop_center
|
base_center = self._crop_center
|
||||||
|
counter = self._export_counter
|
||||||
|
|
||||||
if self._overwrite_path:
|
if self._overwrite_path:
|
||||||
# Group overwrite mode — re-export all sub-clips at this marker.
|
# Group overwrite mode — re-export all sub-clips at this marker.
|
||||||
@@ -2940,16 +2486,29 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
name = self._txt_name.text() or "clip"
|
name = self._txt_name.text() or "clip"
|
||||||
n_clips = self._spn_clips.value()
|
n_clips = self._spn_clips.value()
|
||||||
|
# For subprofile exports, calculate counter independently.
|
||||||
|
if folder_suffix:
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
if image_sequence:
|
||||||
|
p = build_sequence_dir(folder, name, counter, sub=0)
|
||||||
|
else:
|
||||||
|
p = build_export_path(folder, name, counter, sub=0)
|
||||||
|
if not os.path.exists(p):
|
||||||
|
break
|
||||||
|
counter += 1
|
||||||
|
else:
|
||||||
|
counter = self._export_counter
|
||||||
# Create the group subfolder
|
# Create the group subfolder
|
||||||
group_dir = os.path.join(folder, f"{name}_{self._export_counter:03d}")
|
group_dir = os.path.join(folder, f"{name}_{counter:03d}")
|
||||||
os.makedirs(group_dir, exist_ok=True)
|
os.makedirs(group_dir, exist_ok=True)
|
||||||
jobs = []
|
jobs = []
|
||||||
for sub in range(n_clips):
|
for sub in range(n_clips):
|
||||||
start = self._cursor + sub * spread
|
start = self._cursor + sub * spread
|
||||||
if image_sequence:
|
if image_sequence:
|
||||||
out = build_sequence_dir(folder, name, self._export_counter, sub=sub)
|
out = build_sequence_dir(folder, name, counter, sub=sub)
|
||||||
else:
|
else:
|
||||||
out = build_export_path(folder, name, self._export_counter, sub=sub)
|
out = build_export_path(folder, name, counter, sub=sub)
|
||||||
jobs.append((start, out, base_ratio, base_center))
|
jobs.append((start, out, base_ratio, base_center))
|
||||||
|
|
||||||
# Apply crop keyframes (or fall back to base state).
|
# Apply crop keyframes (or fall back to base state).
|
||||||
@@ -3004,14 +2563,18 @@ class MainWindow(QMainWindow):
|
|||||||
self._export_format = fmt
|
self._export_format = fmt
|
||||||
self._export_clip_count = self._spn_clips.value()
|
self._export_clip_count = self._spn_clips.value()
|
||||||
self._export_spread = self._spn_spread.value()
|
self._export_spread = self._spn_spread.value()
|
||||||
|
self._export_folder = folder
|
||||||
|
self._export_folder_suffix = folder_suffix
|
||||||
|
|
||||||
self._btn_export.setEnabled(False)
|
self._btn_export.setEnabled(False)
|
||||||
self._show_status(f"Exporting {len(jobs)} clip(s)…")
|
self._set_subprofile_btns_enabled(False)
|
||||||
|
suffix_tag = f" [{folder_suffix}]" if folder_suffix else ""
|
||||||
|
self._show_status(f"Exporting {len(jobs)} clip(s){suffix_tag}…")
|
||||||
|
|
||||||
# Show one pending marker at the cursor position for the whole batch.
|
# Show one pending marker at the cursor position for the whole batch.
|
||||||
first_out = jobs[0][1]
|
first_out = jobs[0][1]
|
||||||
pending = list(self._timeline._markers)
|
pending = list(self._timeline._markers)
|
||||||
pending.append((self._cursor, self._export_counter, first_out))
|
pending.append((self._cursor, counter, first_out))
|
||||||
self._timeline.set_markers(pending)
|
self._timeline.set_markers(pending)
|
||||||
|
|
||||||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||||||
@@ -3054,8 +2617,7 @@ class MainWindow(QMainWindow):
|
|||||||
spread=self._export_spread,
|
spread=self._export_spread,
|
||||||
profile=self._profile,
|
profile=self._profile,
|
||||||
)
|
)
|
||||||
folder = self._txt_folder.text()
|
upsert_clip_annotation(self._export_folder, path, label)
|
||||||
upsert_clip_annotation(folder, path, label)
|
|
||||||
self._last_export_path = path
|
self._last_export_path = path
|
||||||
_log(f" clip done: {os.path.basename(path)}")
|
_log(f" clip done: {os.path.basename(path)}")
|
||||||
self._show_status(f"Exported: {os.path.basename(path)}")
|
self._show_status(f"Exported: {os.path.basename(path)}")
|
||||||
@@ -3064,9 +2626,9 @@ class MainWindow(QMainWindow):
|
|||||||
"""Called once after all clips in the batch are done."""
|
"""Called once after all clips in the batch are done."""
|
||||||
_log("Batch complete")
|
_log("Batch complete")
|
||||||
self._btn_cancel.setEnabled(False)
|
self._btn_cancel.setEnabled(False)
|
||||||
self._export_counter += 1
|
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
|
self._set_subprofile_btns_enabled(True)
|
||||||
self._btn_export.setText("Export")
|
self._btn_export.setText("Export")
|
||||||
self._btn_export.setStyleSheet("")
|
self._btn_export.setStyleSheet("")
|
||||||
if self._last_export_path:
|
if self._last_export_path:
|
||||||
@@ -3093,6 +2655,7 @@ class MainWindow(QMainWindow):
|
|||||||
_log(f"Export error: {msg}")
|
_log(f"Export error: {msg}")
|
||||||
self._btn_cancel.setEnabled(False)
|
self._btn_cancel.setEnabled(False)
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
|
self._set_subprofile_btns_enabled(True)
|
||||||
self._btn_export.setText("Export")
|
self._btn_export.setText("Export")
|
||||||
self._btn_export.setStyleSheet("")
|
self._btn_export.setStyleSheet("")
|
||||||
self._refresh_markers() # remove stale pending marker
|
self._refresh_markers() # remove stale pending marker
|
||||||
@@ -3107,6 +2670,7 @@ class MainWindow(QMainWindow):
|
|||||||
def _on_export_cancelled(self):
|
def _on_export_cancelled(self):
|
||||||
_log("Export cancelled")
|
_log("Export cancelled")
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
|
self._set_subprofile_btns_enabled(True)
|
||||||
self._btn_export.setText("Export")
|
self._btn_export.setText("Export")
|
||||||
self._btn_export.setStyleSheet("")
|
self._btn_export.setStyleSheet("")
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
|
|||||||
Reference in New Issue
Block a user