Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eab5c690c7 | |||
| 4445f0e7f4 | |||
| ed63d04abf | |||
| 7ae1720b9e | |||
| 514607eddd | |||
| 4299de5f97 | |||
| 86ab606059 | |||
| 87ccd8650c | |||
| ad9e564991 | |||
| 4baac54930 | |||
| 879684ce25 | |||
| 92774216d4 | |||
| 02fd0f0919 | |||
| c537ac678d | |||
| 755f7e5131 | |||
| 1eb7de2a1a | |||
| d7680283a2 | |||
| bf4b6dad2d | |||
| 4715c0ce49 | |||
| e5ce59c065 | |||
| cbbdfeadb1 | |||
| 8a7d761815 | |||
| 140a424469 | |||
| bc6e30a2d4 | |||
| 2ea3a9149a | |||
| e820c106af | |||
| 780832d4aa | |||
| 6037f15e7b | |||
| 035eaf3894 | |||
| 35ea1baec8 | |||
| 6a71386ed8 | |||
| d1fb35af8e | |||
| c55693094d | |||
| 5832d08b26 | |||
| b4cfa7561a | |||
| 0ccc29709e | |||
| 7e917d00a6 | |||
| 2ffb81eaa3 | |||
| b448085242 | |||
| 7cf90c1e5c | |||
| 5aa6878cf6 | |||
| 0e903812fa | |||
| d23ae2e88a | |||
| d97de8de10 | |||
| c6673228fa | |||
| fa4104eded | |||
| 9f7d2e1185 | |||
| c2e6c62c00 | |||
| 8aa8d8805b | |||
| 35c67f4bd5 | |||
| b738a19304 | |||
| dbd8e6a8ac | |||
| 73dfea4ae9 | |||
| 2170e72cbd | |||
| c9915914c4 | |||
| 251747bb0b | |||
| 13c4d3f7f6 | |||
| 1d49ce7cee | |||
| 109bc658c3 | |||
| ec7138f51b | |||
| 68c633ab46 | |||
| d0a94e7b68 | |||
| 632c2dc076 | |||
| 0f335c5e66 | |||
| f1f8fd5244 | |||
| 299779cf29 | |||
| 56218c18f4 | |||
| 2c45aff668 | |||
| 07e2f733b9 | |||
| 8c5a4c4524 | |||
| 4e5b631efb | |||
| ec77b8224f | |||
| 9becd5a06d | |||
| fae5560e2d | |||
| 07e3a1223c | |||
| 3af6e05fb7 | |||
| d787871735 | |||
| 85c08d7c48 | |||
| f6966a092a | |||
| 7cee3ab768 | |||
| 47f910644d | |||
| e972c7a2ae | |||
| cb805c5bda | |||
| bf14247b00 | |||
| 73396659dc | |||
| c8bc629419 | |||
| de8840e1eb | |||
| def966a913 | |||
| bc4ae21153 | |||
| a731fbfc32 |
@@ -3,6 +3,7 @@
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ENV_NAME="8cut"
|
||||
CONDA_PREFIX_BASE="/media/p5/miniforge3"
|
||||
export LD_PRELOAD=/usr/lib/libstdc++.so.6
|
||||
|
||||
# 1. Try .venv in project dir
|
||||
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
||||
|
||||
@@ -30,6 +30,11 @@ mpv_dir = Path(os.environ.get("MPV_DIR", base))
|
||||
|
||||
datas = []
|
||||
|
||||
# Bundled assets (icons, logo) — must exist at runtime under sys._MEIPASS/assets
|
||||
assets_dir = base / "assets"
|
||||
if assets_dir.exists():
|
||||
datas.append((str(assets_dir), "assets"))
|
||||
|
||||
# YOLOv8 model (optional — large, skip if missing)
|
||||
yolo = base / "yolov8n.pt"
|
||||
if yolo.exists():
|
||||
|
||||
@@ -61,6 +61,16 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
|
||||
- **Subprofiles** — lightweight export folder variants for multiple output targets
|
||||
- **Review mode** — clean timeline view for navigating scan results without export clutter
|
||||
|
||||
### Interface
|
||||
|
||||
- **Menu bar** — File / Edit / Scan / View / Help hold the occasional actions (open files, train, scan all, profiles); the profile selector and shortcuts (`?`) sit in the top-right corner
|
||||
- **Control deck** — a compact tabbed panel under the video groups the settings into **Export** (label, name, folder, format, resize, duration/clips/spread, workers), **Crop & Track**, and **Scan** (model, threshold, fuse, scan/auto/speech/review)
|
||||
- **Side-by-side panels** — pin deck panels to view them as resizable columns: right-click a deck tab → *Show side-by-side*, or toggle them under *View ▸ Side-by-side panels*; drag the dividers to reallocate space, and the layout persists between sessions
|
||||
- **Per-tab export folder** — each file-list tab remembers its own output folder; switching tabs follows that tab's folder, and a guardrail warns when the loaded video doesn't match the destination
|
||||
- **Duplicate tab** — right-click a file-list tab → *Duplicate tab* to clone its files into a new tab with its own export folder
|
||||
- **LTX-2 export mode** — per-tab **Foley | LTX-2** toggle (right-click a tab, shown with an `[LTX2]` badge): LTX-2 clips are frame-exact (`frames % 8 == 1`), forced to 25 fps, and center-cropped so width & height are divisible by 32 — for LTX-2 video-to-audio datasets; applies to manual, re-export, and auto-export
|
||||
- **Status bar** — export/scan progress and messages, with the current file · profile · worker count always shown
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g8" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffd230"/>
|
||||
<stop offset="100%" stop-color="#e6a800"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="13" fill="#161616"/>
|
||||
<rect x="8" y="42" width="48" height="11" rx="2" fill="#2a2a2a" stroke="#333" stroke-width="1"/>
|
||||
<rect x="26" y="42" width="16" height="11" fill="#3c82dc" fill-opacity="0.45"/>
|
||||
<line x1="26" y1="38" x2="26" y2="55" stroke="#ffd230" stroke-width="2"/>
|
||||
<polygon points="22,38 30,38 26,44" fill="#ffd230"/>
|
||||
<text x="32" y="33" font-family="'Helvetica Neue',Helvetica,Arial,sans-serif" font-size="34" font-weight="bold" fill="url(#g8)" text-anchor="middle">8</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 790 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M7.5 10 V7.5 a4.5 4.5 0 0 1 9 0 V10" stroke="#ffd230" stroke-width="2"/>
|
||||
<rect x="5" y="10" width="14" height="10" rx="2" fill="#ffd230"/>
|
||||
<circle cx="12" cy="14.3" r="1.4" fill="#161616"/>
|
||||
<rect x="11.2" y="14.3" width="1.6" height="3.4" rx="0.8" fill="#161616"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M7.5 10 V7.5 a4.5 4.5 0 0 1 8.6 -1.8" stroke="#8a8a8a" stroke-width="2"/>
|
||||
<rect x="5" y="10" width="14" height="10" rx="2" fill="#8a8a8a"/>
|
||||
<circle cx="12" cy="14.3" r="1.4" fill="#1e1e1e"/>
|
||||
<rect x="11.2" y="14.3" width="1.6" height="3.4" rx="0.8" fill="#1e1e1e"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<rect x="6.5" y="5" width="4" height="14" rx="1.2" fill="#ffd230"/>
|
||||
<rect x="13.5" y="5" width="4" height="14" rx="1.2" fill="#ffd230"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 209 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M7 5 L19 12 L7 19 Z" fill="#ffd230" stroke="#ffd230" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 177 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aad4ff" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="10.5" cy="10.5" r="6"/>
|
||||
<line x1="15" y1="15" x2="20" y2="20"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 217 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffd230" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6.5" cy="6.5" r="2.6"/>
|
||||
<circle cx="6.5" cy="17.5" r="2.6"/>
|
||||
<line x1="8.8" y1="8" x2="20" y2="17"/>
|
||||
<line x1="8.8" y1="16" x2="20" y2="7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffd230" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4,17 10,11 14,14 20,6"/>
|
||||
<polyline points="15,6 20,6 20,11"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -1,2 +1,6 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "gui: constructs Qt widgets; needs a display")
|
||||
|
||||
@@ -67,7 +67,7 @@ _EMBED_MODELS = {
|
||||
"EAT": 768,
|
||||
"EAT_LARGE": 1024,
|
||||
}
|
||||
_DEFAULT_EMBED_MODEL = "WAV2VEC2_BASE"
|
||||
_DEFAULT_EMBED_MODEL = "EAT_LARGE"
|
||||
|
||||
_BEATS_CHECKPOINT = os.path.join(
|
||||
_DL_CACHE_DIR, "huggingface", "hub",
|
||||
@@ -674,9 +674,11 @@ def restore_model_version(version_path: str, profile_name: str = "default",
|
||||
|
||||
|
||||
def list_trained_models(profile_name: str = "default") -> list[str]:
|
||||
"""Return embedding model names that have a trained .joblib for *profile_name*.
|
||||
"""Return embedding model keys that have a trained .joblib for *profile_name*.
|
||||
|
||||
Looks for files matching ``{profile}_{MODEL}.joblib`` in the models dir.
|
||||
Looks for files matching ``{profile}_{KEY}.joblib`` in the models dir.
|
||||
KEY is either a bare embed model name (e.g. ``EAT_LARGE``) or
|
||||
``{MODEL}_{name}`` for user-named variants.
|
||||
"""
|
||||
prefix = f"{profile_name}_"
|
||||
suffix = ".joblib"
|
||||
@@ -685,13 +687,17 @@ def list_trained_models(profile_name: str = "default") -> list[str]:
|
||||
return result
|
||||
for fname in os.listdir(_MODEL_DIR):
|
||||
if fname.startswith(prefix) and fname.endswith(suffix):
|
||||
model_name = fname[len(prefix):-len(suffix)]
|
||||
if model_name in _EMBED_MODELS:
|
||||
result.append(model_name)
|
||||
key = fname[len(prefix):-len(suffix)]
|
||||
if key in _EMBED_MODELS:
|
||||
result.append(key)
|
||||
else:
|
||||
for m in _EMBED_MODELS:
|
||||
if key.startswith(m + "_"):
|
||||
result.append(key)
|
||||
break
|
||||
# Also check legacy {profile}.joblib
|
||||
legacy = os.path.join(_MODEL_DIR, f"{profile_name}.joblib")
|
||||
if os.path.exists(legacy) and not result:
|
||||
# Legacy model — we don't know the embed model, but it's usable
|
||||
result.append("")
|
||||
return sorted(result)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
@@ -7,6 +8,12 @@ from pathlib import Path
|
||||
from .paths import _log
|
||||
|
||||
|
||||
def _extract_m_number(output_path: str) -> int | None:
|
||||
"""Extract the manual export number from a path like clip_001_m3_0.mp4."""
|
||||
m = re.search(r'_m(\d+)[_.]', os.path.basename(output_path))
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
class ProcessedDB:
|
||||
_SCHEMA_VERSION = 4 # bump when schema changes
|
||||
|
||||
@@ -17,6 +24,18 @@ class ProcessedDB:
|
||||
self._lock = threading.Lock()
|
||||
try:
|
||||
self._con = sqlite3.connect(db_path, check_same_thread=False)
|
||||
# Performance pragmas: WAL cuts lock contention and fsync cost,
|
||||
# a bigger page cache keeps hot scans in memory.
|
||||
for pragma in (
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA synchronous = NORMAL",
|
||||
"PRAGMA temp_store = MEMORY",
|
||||
"PRAGMA cache_size = -65536", # ~64 MB
|
||||
):
|
||||
try:
|
||||
self._con.execute(pragma)
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
self._migrate()
|
||||
self._enabled = True
|
||||
_log(f"DB opened: {db_path}")
|
||||
@@ -46,6 +65,7 @@ class ProcessedDB:
|
||||
" crop_center REAL NOT NULL DEFAULT 0.5,"
|
||||
" format TEXT NOT NULL DEFAULT 'MP4',"
|
||||
" clip_count INTEGER NOT NULL DEFAULT 3,"
|
||||
" clip_duration REAL NOT NULL DEFAULT 8.0,"
|
||||
" spread REAL NOT NULL DEFAULT 3.0,"
|
||||
" profile TEXT NOT NULL DEFAULT 'default',"
|
||||
" source_path TEXT NOT NULL DEFAULT '',"
|
||||
@@ -63,6 +83,7 @@ class ProcessedDB:
|
||||
"crop_center": "REAL NOT NULL DEFAULT 0.5",
|
||||
"format": "TEXT NOT NULL DEFAULT 'MP4'",
|
||||
"clip_count": "INTEGER NOT NULL DEFAULT 3",
|
||||
"clip_duration": "REAL NOT NULL DEFAULT 8.0",
|
||||
"spread": "REAL NOT NULL DEFAULT 3.0",
|
||||
"profile": "TEXT NOT NULL DEFAULT 'default'",
|
||||
"source_path": "TEXT NOT NULL DEFAULT ''",
|
||||
@@ -76,6 +97,11 @@ class ProcessedDB:
|
||||
self._con.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
|
||||
)
|
||||
# Most hot queries filter by profile, often with filename too.
|
||||
self._con.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_profile_filename"
|
||||
" ON processed(profile, filename)"
|
||||
)
|
||||
self._con.execute(
|
||||
"CREATE TABLE IF NOT EXISTS hidden_files ("
|
||||
" filename TEXT NOT NULL,"
|
||||
@@ -232,7 +258,8 @@ class ProcessedDB:
|
||||
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,
|
||||
clip_count: int = 3, clip_duration: float = 8.0,
|
||||
spread: float = 3.0,
|
||||
profile: str = "default", source_path: str = "",
|
||||
scan_export: bool = False) -> None:
|
||||
if not self._enabled:
|
||||
@@ -242,16 +269,60 @@ class ProcessedDB:
|
||||
"INSERT INTO processed"
|
||||
" (filename, start_time, output_path, label, category,"
|
||||
" short_side, portrait_ratio, crop_center, format,"
|
||||
" clip_count, spread, profile, source_path, scan_export, processed_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
" clip_count, clip_duration, spread, profile, source_path,"
|
||||
" scan_export, processed_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(filename, start_time, output_path, label, category,
|
||||
short_side, portrait_ratio, crop_center, fmt,
|
||||
clip_count, spread, profile, source_path,
|
||||
clip_count, clip_duration, spread, profile, source_path,
|
||||
1 if scan_export else 0,
|
||||
datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def update_source_paths(self, new_dir: str,
|
||||
playlist_paths: list[str] | None = None,
|
||||
profile: str = "") -> int:
|
||||
"""Re-resolve source_path for all rows whose current path is missing.
|
||||
|
||||
Checks *new_dir* and *playlist_paths* by filename match.
|
||||
Returns the number of rows updated.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return 0
|
||||
lookup: dict[str, str] = {}
|
||||
if playlist_paths:
|
||||
for p in playlist_paths:
|
||||
lookup[os.path.basename(p)] = p
|
||||
if new_dir and os.path.isdir(new_dir):
|
||||
for f in os.listdir(new_dir):
|
||||
fp = os.path.join(new_dir, f)
|
||||
if os.path.isfile(fp):
|
||||
lookup[f] = fp
|
||||
if not lookup:
|
||||
return 0
|
||||
query = "SELECT DISTINCT filename, source_path FROM processed"
|
||||
params: tuple = ()
|
||||
if profile:
|
||||
query += " WHERE profile = ?"
|
||||
params = (profile,)
|
||||
rows = self._con.execute(query, params).fetchall()
|
||||
updated = 0
|
||||
with self._lock:
|
||||
for fn, sp in rows:
|
||||
if sp and os.path.exists(sp):
|
||||
continue
|
||||
new_path = lookup.get(fn)
|
||||
if new_path and os.path.isfile(new_path):
|
||||
self._con.execute(
|
||||
"UPDATE processed SET source_path = ? WHERE filename = ?",
|
||||
(new_path, fn),
|
||||
)
|
||||
updated += 1
|
||||
if updated:
|
||||
self._con.commit()
|
||||
return updated
|
||||
|
||||
def get_labels(self) -> list[str]:
|
||||
"""Return distinct non-empty labels ordered by most recently used."""
|
||||
if not self._enabled:
|
||||
@@ -278,19 +349,37 @@ class ProcessedDB:
|
||||
cur.row_factory = sqlite3.Row
|
||||
row = cur.execute(
|
||||
"SELECT label, category, short_side, portrait_ratio, crop_center, format,"
|
||||
" clip_count, spread"
|
||||
" clip_count, clip_duration, spread"
|
||||
" FROM processed WHERE output_path = ?",
|
||||
(output_path,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def delete_by_output_path(self, output_path: str) -> None:
|
||||
def delete_by_output_path(self, output_path: str, profile: str = "") -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
with self._lock:
|
||||
self._con.execute("DELETE FROM processed WHERE output_path = ?", (output_path,))
|
||||
if profile:
|
||||
self._con.execute(
|
||||
"DELETE FROM processed WHERE output_path = ? AND profile = ?",
|
||||
(output_path, profile),
|
||||
)
|
||||
else:
|
||||
self._con.execute(
|
||||
"DELETE FROM processed WHERE output_path = ?", (output_path,),
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def is_path_used_by_other_profiles(self, output_path: str, profile: str) -> bool:
|
||||
"""Return True if *output_path* is referenced by any profile other than *profile*."""
|
||||
if not self._enabled:
|
||||
return False
|
||||
row = self._con.execute(
|
||||
"SELECT 1 FROM processed WHERE output_path = ? AND profile != ? LIMIT 1",
|
||||
(output_path, profile),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def get_group(self, output_path: str, profile: str = "") -> list[str]:
|
||||
"""Return all output_paths sharing the same (filename, start_time, profile) as *output_path*."""
|
||||
if not self._enabled:
|
||||
@@ -336,29 +425,120 @@ class ProcessedDB:
|
||||
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 = ? AND scan_export = 0"
|
||||
" 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:
|
||||
def _get_markers_for(self, match: str, profile: str = "default",
|
||||
export_folder: str = "") -> list[tuple[float, int, str, float]]:
|
||||
if export_folder:
|
||||
rows = self._con.execute(
|
||||
"SELECT start_time, output_path, clip_duration, clip_count, spread"
|
||||
" FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 0"
|
||||
" AND output_path LIKE ?"
|
||||
" ORDER BY start_time",
|
||||
(match, profile, export_folder.rstrip("/") + "/%"),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._con.execute(
|
||||
"SELECT start_time, output_path, clip_duration, clip_count, spread"
|
||||
" FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 0"
|
||||
" ORDER BY start_time",
|
||||
(match, profile),
|
||||
).fetchall()
|
||||
seen_times: dict[float, tuple[float, int, str, float]] = {}
|
||||
seq = 0
|
||||
for t, p, dur, cnt, spr in rows:
|
||||
if t not in seen_times:
|
||||
n += 1
|
||||
seen_times[t] = (t, n, p)
|
||||
seq += 1
|
||||
num = _extract_m_number(p) or seq
|
||||
span = (dur or 8.0) + ((cnt or 1) - 1) * (spr or 3.0)
|
||||
seen_times[t] = (t, num, p, span)
|
||||
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.
|
||||
Excludes scan exports (shown via scan panel instead)."""
|
||||
def get_markers(self, filename: str, profile: str = "default",
|
||||
export_folder: str = "") -> list[tuple[float, int, str, float]]:
|
||||
"""Return [(start_time, marker_number, output_path, clip_span), ...]
|
||||
for exact filename match, sorted by start_time. Empty list if no match.
|
||||
Excludes scan exports (shown via scan panel instead).
|
||||
If export_folder is set, only markers in that folder are returned."""
|
||||
if not self._enabled:
|
||||
return []
|
||||
return self._get_markers_for(filename, profile)
|
||||
return self._get_markers_for(filename, profile, export_folder)
|
||||
|
||||
def get_other_folder_markers(self, filename: str, profile: str = "default",
|
||||
export_folder: str = ""
|
||||
) -> dict[str, list[tuple[float, int, str, float]]]:
|
||||
"""Return {folder_name: [(start_time, num, path, span), ...]} for
|
||||
markers NOT in export_folder, grouped by their base export folder."""
|
||||
if not self._enabled or not export_folder:
|
||||
return {}
|
||||
rows = self._con.execute(
|
||||
"SELECT start_time, output_path, clip_duration, clip_count, spread"
|
||||
" FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 0"
|
||||
" AND output_path NOT LIKE ?"
|
||||
" ORDER BY start_time",
|
||||
(filename, profile, export_folder.rstrip("/") + "/%"),
|
||||
).fetchall()
|
||||
by_folder: dict[str, list] = {}
|
||||
for t, p, dur, cnt, spr in rows:
|
||||
parts = p.split("/")
|
||||
for i, part in enumerate(parts):
|
||||
if part.startswith("vid_"):
|
||||
folder = "/".join(parts[:i])
|
||||
break
|
||||
else:
|
||||
folder = os.path.dirname(os.path.dirname(p))
|
||||
by_folder.setdefault(folder, []).append((t, p, dur, cnt, spr))
|
||||
result: dict[str, list[tuple[float, int, str, float]]] = {}
|
||||
for folder, folder_rows in by_folder.items():
|
||||
seen: dict[float, tuple[float, int, str, float]] = {}
|
||||
seq = 0
|
||||
for t, p, dur, cnt, spr in folder_rows:
|
||||
if t not in seen:
|
||||
seq += 1
|
||||
num = _extract_m_number(p) or seq
|
||||
span = (dur or 8.0) + ((cnt or 1) - 1) * (spr or 3.0)
|
||||
seen[t] = (t, num, p, span)
|
||||
name = os.path.basename(folder)
|
||||
if name.endswith("_disabled"):
|
||||
continue # disabled clips are excluded from the timeline
|
||||
result[name] = list(seen.values())
|
||||
return result
|
||||
|
||||
def get_manual_export_groups(self, filename: str, profile: str = "default"
|
||||
) -> list[dict]:
|
||||
"""Return manual (non-scan) export groups for *filename*.
|
||||
|
||||
Each group dict has:
|
||||
start_time, paths (list[str] sorted), clip_count, clip_duration,
|
||||
spread, short_side, portrait_ratio, crop_center, format, label,
|
||||
category
|
||||
"""
|
||||
if not self._enabled:
|
||||
return []
|
||||
rows = self._con.execute(
|
||||
"SELECT start_time, output_path, clip_count, clip_duration, spread,"
|
||||
" short_side, portrait_ratio, crop_center, format, label, category"
|
||||
" FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 0"
|
||||
" ORDER BY start_time, output_path",
|
||||
(filename, profile),
|
||||
).fetchall()
|
||||
groups: dict[float, dict] = {}
|
||||
for r in rows:
|
||||
t = r[0]
|
||||
if t not in groups:
|
||||
groups[t] = {
|
||||
"start_time": t,
|
||||
"paths": [],
|
||||
"clip_count": r[2], "clip_duration": r[3],
|
||||
"spread": r[4],
|
||||
"short_side": r[5], "portrait_ratio": r[6],
|
||||
"crop_center": r[7], "format": r[8],
|
||||
"label": r[9], "category": r[10],
|
||||
}
|
||||
groups[t]["paths"].append(r[1])
|
||||
return list(groups.values())
|
||||
|
||||
def get_clip_count(self, filename: str, profile: str = "default") -> int:
|
||||
"""Return total number of exported clips (including scan exports)."""
|
||||
@@ -370,15 +550,254 @@ class ProcessedDB:
|
||||
).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def get_clip_counts_by_folder(self, filename: str,
|
||||
profile: str = "default") -> dict[str, int]:
|
||||
"""Return per-export-folder clip counts for a single video.
|
||||
|
||||
Folder name is the grandparent dir of each clip's output_path
|
||||
(e.g. ``mp4_doggy_clap``).
|
||||
"""
|
||||
if not self._enabled:
|
||||
return {}
|
||||
rows = self._con.execute(
|
||||
"SELECT output_path FROM processed WHERE filename = ? AND profile = ?",
|
||||
(filename, profile),
|
||||
).fetchall()
|
||||
counts: dict[str, int] = {}
|
||||
for (op,) in rows:
|
||||
folder = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
||||
counts[folder] = counts.get(folder, 0) + 1
|
||||
return counts
|
||||
|
||||
def get_clip_counts_grouped(self, profile: str = "default"
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Return ``{filename: {export_folder: count}}`` for a whole profile
|
||||
in a single scan (replaces N per-file queries on the hot path)."""
|
||||
if not self._enabled:
|
||||
return {}
|
||||
rows = self._con.execute(
|
||||
"SELECT filename, output_path FROM processed WHERE profile = ?",
|
||||
(profile,),
|
||||
).fetchall()
|
||||
out: dict[str, dict[str, int]] = {}
|
||||
for fn, op in rows:
|
||||
folder = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
||||
d = out.get(fn)
|
||||
if d is None:
|
||||
d = out[fn] = {}
|
||||
d[folder] = d.get(folder, 0) + 1
|
||||
return out
|
||||
|
||||
def get_all_folder_counts(self, profile: str = "default") -> dict[str, int]:
|
||||
"""Return clip counts per export folder across all videos in *profile*.
|
||||
|
||||
Includes ``_disabled`` folders so callers can offer enable/disable.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return {}
|
||||
rows = self._con.execute(
|
||||
"SELECT output_path FROM processed WHERE profile = ?",
|
||||
(profile,),
|
||||
).fetchall()
|
||||
counts: dict[str, int] = {}
|
||||
for (op,) in rows:
|
||||
folder = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
||||
counts[folder] = counts.get(folder, 0) + 1
|
||||
return counts
|
||||
|
||||
def relocate_video_clips(self, filename: "str | None", profile: str,
|
||||
src_folder_name: str,
|
||||
dst_folder_name: str) -> int:
|
||||
"""Move clips from one export folder to a sibling folder.
|
||||
|
||||
Matches rows whose grandparent dir basename == *src_folder_name*
|
||||
(restricted to *filename* when given, else every video in *profile*),
|
||||
then moves each clip (and any ``.wav`` sidecar) on disk into a sibling
|
||||
folder named *dst_folder_name*, migrates its dataset.json annotation,
|
||||
and rewrites output_path in the DB. Returns the number of clips moved.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return 0
|
||||
import shutil
|
||||
from .annotations import remove_clip_annotation, upsert_clip_annotation
|
||||
|
||||
if filename is None:
|
||||
rows = self._con.execute(
|
||||
"SELECT id, output_path, label FROM processed WHERE profile = ?",
|
||||
(profile,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._con.execute(
|
||||
"SELECT id, output_path, label FROM processed"
|
||||
" WHERE filename = ? AND profile = ?",
|
||||
(filename, profile),
|
||||
).fetchall()
|
||||
|
||||
moves: list[tuple[str, str]] = [] # (old_path, new_path)
|
||||
updates: list[tuple[str, int]] = [] # (new_path, id)
|
||||
ann: list[tuple[str, str, str, str, str]] = [] # old_fold,new_fold,old,new,label
|
||||
new_dirs: set[str] = set()
|
||||
old_vid_dirs: set[str] = set()
|
||||
|
||||
for rid, op, label in rows:
|
||||
vid_dir = os.path.dirname(op)
|
||||
export_folder = os.path.dirname(vid_dir)
|
||||
if os.path.basename(export_folder) != src_folder_name:
|
||||
continue
|
||||
new_export_folder = os.path.join(
|
||||
os.path.dirname(export_folder), dst_folder_name)
|
||||
new_vid_dir = os.path.join(new_export_folder, os.path.basename(vid_dir))
|
||||
new_op = os.path.join(new_vid_dir, os.path.basename(op))
|
||||
updates.append((new_op, rid))
|
||||
new_dirs.add(new_vid_dir)
|
||||
old_vid_dirs.add(vid_dir)
|
||||
if os.path.exists(op):
|
||||
moves.append((op, new_op))
|
||||
ann.append((export_folder, new_export_folder, op, new_op, label or ""))
|
||||
|
||||
if not updates:
|
||||
return 0
|
||||
|
||||
with self._lock:
|
||||
for d in sorted(new_dirs):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
for old, new in moves:
|
||||
if os.path.exists(old) and not os.path.exists(new):
|
||||
shutil.move(old, new)
|
||||
wav_old, wav_new = old + ".wav", new + ".wav"
|
||||
if os.path.exists(wav_old) and not os.path.exists(wav_new):
|
||||
shutil.move(wav_old, wav_new)
|
||||
self._con.executemany(
|
||||
"UPDATE processed SET output_path = ? WHERE id = ?", updates)
|
||||
self._con.commit()
|
||||
|
||||
# Migrate dataset.json entries (best-effort, outside the DB lock).
|
||||
for old_fold, new_fold, old_op, new_op, label in ann:
|
||||
remove_clip_annotation(old_fold, old_op)
|
||||
if label:
|
||||
upsert_clip_annotation(new_fold, new_op, label)
|
||||
|
||||
# Remove now-empty old vid dirs and their export folder if empty.
|
||||
for d in sorted(old_vid_dirs):
|
||||
try:
|
||||
if os.path.isdir(d) and not os.listdir(d):
|
||||
os.rmdir(d)
|
||||
parent = os.path.dirname(d)
|
||||
if os.path.isdir(parent) and not os.listdir(parent):
|
||||
os.rmdir(parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_log(f"Relocated {len(updates)} clip(s) of {filename or 'all videos'}: "
|
||||
f"{src_folder_name} -> {dst_folder_name}")
|
||||
return len(updates)
|
||||
|
||||
def get_profiles(self) -> list[str]:
|
||||
"""Return distinct profile names, ordered alphabetically."""
|
||||
"""Return distinct profile names across all tables, ordered alphabetically."""
|
||||
if not self._enabled:
|
||||
return []
|
||||
rows = self._con.execute(
|
||||
"SELECT DISTINCT profile FROM processed ORDER BY profile"
|
||||
"SELECT DISTINCT profile FROM processed"
|
||||
" UNION SELECT DISTINCT profile FROM scan_results"
|
||||
" UNION SELECT DISTINCT profile FROM hard_negatives"
|
||||
" ORDER BY profile"
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def duplicate_profile(self, src: str, dst: str) -> int:
|
||||
"""Copy all profile data from *src* to *dst*.
|
||||
|
||||
Copies processed (exports), scan_results, hard_negatives, and
|
||||
hidden_files. Returns total number of rows copied.
|
||||
"""
|
||||
if not self._enabled or src == dst:
|
||||
return 0
|
||||
total = 0
|
||||
with self._lock:
|
||||
# processed (exports)
|
||||
rows = self._con.execute(
|
||||
"SELECT filename, start_time, output_path, label, category,"
|
||||
" short_side, portrait_ratio, crop_center, format,"
|
||||
" clip_count, clip_duration, spread, source_path, scan_export,"
|
||||
" processed_at"
|
||||
" FROM processed WHERE profile = ?", (src,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
self._con.execute(
|
||||
"INSERT INTO processed"
|
||||
" (filename, start_time, output_path, label, category,"
|
||||
" short_side, portrait_ratio, crop_center, format,"
|
||||
" clip_count, clip_duration, spread, profile,"
|
||||
" source_path, scan_export, processed_at)"
|
||||
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(*r[:12], dst, *r[12:]),
|
||||
)
|
||||
total += len(rows)
|
||||
# scan_results
|
||||
rows = self._con.execute(
|
||||
"SELECT filename, model, start_time, end_time, score,"
|
||||
" disabled, orig_start_time, orig_end_time, scan_timestamp"
|
||||
" FROM scan_results WHERE profile = ?", (src,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
self._con.execute(
|
||||
"INSERT INTO scan_results"
|
||||
" (filename, profile, model, start_time, end_time, score,"
|
||||
" disabled, orig_start_time, orig_end_time, scan_timestamp)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(r[0], dst, r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8]),
|
||||
)
|
||||
total += len(rows)
|
||||
# hard_negatives
|
||||
rows = self._con.execute(
|
||||
"SELECT filename, start_time, source_path, source_model"
|
||||
" FROM hard_negatives WHERE profile = ?", (src,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
self._con.execute(
|
||||
"INSERT INTO hard_negatives"
|
||||
" (filename, profile, start_time, source_path, source_model)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(r[0], dst, r[1], r[2], r[3]),
|
||||
)
|
||||
total += len(rows)
|
||||
# hidden_files
|
||||
rows = self._con.execute(
|
||||
"SELECT filename FROM hidden_files WHERE profile = ?", (src,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
self._con.execute(
|
||||
"INSERT OR IGNORE INTO hidden_files (filename, profile)"
|
||||
" VALUES (?, ?)",
|
||||
(r[0], dst),
|
||||
)
|
||||
total += len(rows)
|
||||
self._con.commit()
|
||||
return total
|
||||
|
||||
def count_profile_rows(self, profile: str) -> int:
|
||||
"""Return total number of rows across all tables for *profile*."""
|
||||
if not self._enabled:
|
||||
return 0
|
||||
n = 0
|
||||
for table in ("processed", "scan_results", "hard_negatives", "hidden_files"):
|
||||
row = self._con.execute(
|
||||
f"SELECT COUNT(*) FROM {table} WHERE profile = ?", (profile,),
|
||||
).fetchone()
|
||||
n += row[0] if row else 0
|
||||
return n
|
||||
|
||||
def delete_profile(self, profile: str) -> None:
|
||||
"""Delete all rows for *profile* from every table."""
|
||||
if not self._enabled:
|
||||
return
|
||||
with self._lock:
|
||||
for table in ("processed", "scan_results", "hard_negatives", "hidden_files"):
|
||||
self._con.execute(
|
||||
f"DELETE FROM {table} WHERE profile = ?", (profile,),
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def get_all_export_paths(self, profile: str = "default") -> list[str]:
|
||||
"""Return all unique output_path values for a given profile."""
|
||||
if not self._enabled:
|
||||
@@ -418,6 +837,32 @@ class ProcessedDB:
|
||||
pass
|
||||
return max_n
|
||||
|
||||
def get_scan_export_rep_paths_in_range(self, filename: str, profile: str,
|
||||
start: float, end: float) -> list[str]:
|
||||
"""Return one representative output_path per distinct scan-export
|
||||
start_time inside [start, end] for (filename, profile)."""
|
||||
if not self._enabled:
|
||||
return []
|
||||
rows = self._con.execute(
|
||||
"SELECT output_path FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 1"
|
||||
" AND start_time BETWEEN ? AND ?"
|
||||
" GROUP BY start_time",
|
||||
(filename, profile, start, end),
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def get_scan_export_times(self, filename: str, profile: str) -> list[float]:
|
||||
"""Return start_times of scan_export=1 rows for this file/profile."""
|
||||
if not self._enabled:
|
||||
return []
|
||||
rows = self._con.execute(
|
||||
"SELECT start_time FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 1",
|
||||
(filename, profile),
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def delete_scan_exports(self, filename: str, profile: str) -> int:
|
||||
"""Delete all scan_export entries for *filename* in *profile*.
|
||||
|
||||
@@ -504,13 +949,15 @@ class ProcessedDB:
|
||||
folder_names: set[str] = set()
|
||||
for (op,) in rows:
|
||||
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
||||
if grandparent:
|
||||
if grandparent and not grandparent.endswith("_disabled"):
|
||||
folder_names.add(grandparent)
|
||||
return sorted(folder_names)
|
||||
|
||||
def get_training_data(self, profile: str, positive_folder: str,
|
||||
def get_training_data(self, profile: str,
|
||||
positive_folder: "str | list[str]",
|
||||
negative_folder: str = "",
|
||||
fallback_video_dir: str = "",
|
||||
playlist_paths: list[str] | None = None,
|
||||
include_scan_exports: bool = False,
|
||||
use_hard_negatives: bool = True,
|
||||
) -> list[tuple[str, list[float], list[float], list[float]]]:
|
||||
@@ -518,18 +965,20 @@ class ProcessedDB:
|
||||
|
||||
Args:
|
||||
profile: profile name
|
||||
positive_folder: export folder name for positive class (e.g. "mp4_Intense")
|
||||
positive_folder: export folder name(s) for positive class
|
||||
negative_folder: export folder name for explicit negatives (optional)
|
||||
fallback_video_dir: if source_path is empty, try filename in this dir
|
||||
playlist_paths: loaded playlist paths to resolve filenames
|
||||
include_scan_exports: if True, include auto-exported scan clips
|
||||
use_hard_negatives: if False, skip hard negatives from scan feedback
|
||||
|
||||
Returns:
|
||||
list of (source_video_path, positive_times, soft_times, negative_times)
|
||||
per video. Soft times = clips from any other non-negative folder.
|
||||
per video. Soft times = clips from any other non-positive/non-negative folder.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return []
|
||||
pos_folders = {positive_folder} if isinstance(positive_folder, str) else set(positive_folder)
|
||||
if include_scan_exports:
|
||||
rows = self._con.execute(
|
||||
"SELECT filename, start_time, output_path, source_path"
|
||||
@@ -553,7 +1002,9 @@ class ProcessedDB:
|
||||
if sp:
|
||||
source_by_filename[fn] = sp
|
||||
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
||||
if grandparent == positive_folder:
|
||||
if grandparent.endswith("_disabled"):
|
||||
continue # disabled clips are excluded from training entirely
|
||||
if grandparent in pos_folders:
|
||||
pos_by_video.setdefault(fn, set()).add(st)
|
||||
elif negative_folder and grandparent == negative_folder:
|
||||
neg_by_video.setdefault(fn, set()).add(st)
|
||||
@@ -590,11 +1041,19 @@ class ProcessedDB:
|
||||
result.append(t)
|
||||
return result
|
||||
|
||||
# Build filename→path lookup from playlist
|
||||
playlist_lookup: dict[str, str] = {}
|
||||
if playlist_paths:
|
||||
for p in playlist_paths:
|
||||
playlist_lookup[os.path.basename(p)] = p
|
||||
|
||||
# Include videos that have positives OR explicit negatives
|
||||
all_videos = set(pos_by_video) | set(neg_by_video)
|
||||
result = []
|
||||
for fn in all_videos:
|
||||
sp = source_by_filename.get(fn, "")
|
||||
if not sp or not os.path.exists(sp):
|
||||
sp = playlist_lookup.get(fn, "")
|
||||
if not sp or not os.path.exists(sp):
|
||||
if fallback_video_dir:
|
||||
sp = os.path.join(fallback_video_dir, fn)
|
||||
@@ -628,18 +1087,18 @@ class ProcessedDB:
|
||||
" WHERE profile = ? AND scan_export = 0",
|
||||
(profile,),
|
||||
).fetchall()
|
||||
folders = self.get_export_folders(profile, include_scan_exports=include_scan_exports)
|
||||
stats: dict[str, dict] = {}
|
||||
for folder_name in folders:
|
||||
videos: set[str] = set()
|
||||
clips = 0
|
||||
for fn, op in rows:
|
||||
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
||||
if grandparent == folder_name:
|
||||
videos.add(fn)
|
||||
clips += 1
|
||||
stats[folder_name] = {"videos": len(videos), "clips": clips}
|
||||
return {k: v for k, v in stats.items() if v["clips"] > 0}
|
||||
# Single pass: group by export folder (grandparent dir), counting
|
||||
# clips and distinct source videos. (Was O(folders × rows).)
|
||||
videos: dict[str, set[str]] = {}
|
||||
clips: dict[str, int] = {}
|
||||
for fn, op in rows:
|
||||
folder_name = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
||||
if not folder_name or folder_name.endswith("_disabled"):
|
||||
continue
|
||||
videos.setdefault(folder_name, set()).add(fn)
|
||||
clips[folder_name] = clips.get(folder_name, 0) + 1
|
||||
return {f: {"videos": len(videos[f]), "clips": n}
|
||||
for f, n in clips.items() if n > 0}
|
||||
|
||||
# ── Scan results ─────────────────────────────────────────────
|
||||
|
||||
@@ -746,6 +1205,52 @@ class ProcessedDB:
|
||||
oe if oe is not None else e))
|
||||
return result
|
||||
|
||||
def read_scan_bundle(self, filename: str, profile: str):
|
||||
"""Read (hard_negative_times, scan_export_times, scan_results) for a file.
|
||||
|
||||
Uses a fresh short-lived connection so it is safe to call from a worker
|
||||
thread (WAL allows concurrent readers alongside the main connection).
|
||||
Returns (set[float], list[float], dict[model -> rows]).
|
||||
"""
|
||||
if not self._enabled:
|
||||
return set(), [], {}
|
||||
try:
|
||||
con = sqlite3.connect(self._path)
|
||||
except sqlite3.Error:
|
||||
return set(), [], {}
|
||||
try:
|
||||
neg = {r[0] for r in con.execute(
|
||||
"SELECT start_time FROM hard_negatives"
|
||||
" WHERE filename = ? AND profile = ?",
|
||||
(filename, profile))}
|
||||
exported = [r[0] for r in con.execute(
|
||||
"SELECT start_time FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 1",
|
||||
(filename, profile))]
|
||||
rows = con.execute(
|
||||
"SELECT r.id, r.model, r.start_time, r.end_time, r.score,"
|
||||
" r.disabled, r.orig_start_time, r.orig_end_time"
|
||||
" FROM scan_results r"
|
||||
" INNER JOIN ("
|
||||
" SELECT model, MAX(scan_timestamp) AS latest"
|
||||
" FROM scan_results"
|
||||
" WHERE filename = ? AND profile = ?"
|
||||
" GROUP BY model"
|
||||
" ) m ON r.model = m.model AND r.scan_timestamp = m.latest"
|
||||
" WHERE r.filename = ? AND r.profile = ?"
|
||||
" ORDER BY r.model, r.start_time",
|
||||
(filename, profile, filename, profile)).fetchall()
|
||||
results: dict = {}
|
||||
for row_id, model, s, e, sc, dis, os_, oe in rows:
|
||||
results.setdefault(model, []).append(
|
||||
(row_id, s, e, sc, bool(dis),
|
||||
os_ if os_ is not None else s, oe if oe is not None else e))
|
||||
return neg, exported, results
|
||||
except sqlite3.Error:
|
||||
return set(), [], {}
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def delete_scan_result(self, row_id: int) -> None:
|
||||
"""Delete a single scan result row."""
|
||||
if not self._enabled:
|
||||
@@ -777,6 +1282,41 @@ class ProcessedDB:
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def insert_scan_result(self, filename: str, profile: str, model: str,
|
||||
start: float, end: float, score: float,
|
||||
disabled: bool, orig_start: float, orig_end: float,
|
||||
scan_timestamp: str = "") -> int:
|
||||
"""Insert a single scan result row; returns its new id."""
|
||||
if not self._enabled:
|
||||
return -1
|
||||
with self._lock:
|
||||
cur = self._con.execute(
|
||||
"INSERT INTO scan_results"
|
||||
" (filename, profile, model, start_time, end_time, score,"
|
||||
" disabled, orig_start_time, orig_end_time, scan_timestamp)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(filename, profile, model, start, end, score,
|
||||
1 if disabled else 0, orig_start, orig_end, scan_timestamp),
|
||||
)
|
||||
self._con.commit()
|
||||
return int(cur.lastrowid or -1)
|
||||
|
||||
def update_scan_result_full(self, row_id: int, start: float, end: float,
|
||||
score: float, orig_start: float,
|
||||
orig_end: float) -> None:
|
||||
"""Update bounds, score and orig_* fields — used after merging rows."""
|
||||
if not self._enabled:
|
||||
return
|
||||
with self._lock:
|
||||
self._con.execute(
|
||||
"UPDATE scan_results"
|
||||
" SET start_time = ?, end_time = ?, score = ?,"
|
||||
" orig_start_time = ?, orig_end_time = ?"
|
||||
" WHERE id = ?",
|
||||
(start, end, score, orig_start, orig_end, row_id),
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def get_scan_models(self, filename: str, profile: str) -> list[str]:
|
||||
"""Return model names that have scan results for this file."""
|
||||
if not self._enabled:
|
||||
|
||||
@@ -78,6 +78,10 @@ def build_ffmpeg_command(
|
||||
crop_center: float = 0.5,
|
||||
image_sequence: bool = False,
|
||||
encoder: str = "libx264",
|
||||
duration: float = 8.0,
|
||||
target_fps: float | None = None,
|
||||
snap32: bool = False,
|
||||
frames: int | None = None,
|
||||
) -> 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.
|
||||
@@ -96,7 +100,7 @@ def build_ffmpeg_command(
|
||||
"-threads", "0",
|
||||
"-ss", str(start),
|
||||
"-i", input_path,
|
||||
"-t", "8",
|
||||
"-t", str(duration),
|
||||
]
|
||||
|
||||
filters: list[str] = []
|
||||
@@ -108,6 +112,13 @@ def build_ffmpeg_command(
|
||||
f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})':flags=lanczos"
|
||||
)
|
||||
|
||||
# LTX-2: centered crop to ÷32 (no rescale → no aspect distortion) then fps.
|
||||
# Placed among CPU filters, after scale and before the VAAPI hwupload block.
|
||||
if snap32:
|
||||
filters.append("crop=trunc(iw/32)*32:trunc(ih/32)*32")
|
||||
if target_fps is not None:
|
||||
filters.append(f"fps={target_fps:g}")
|
||||
|
||||
# VAAPI: decoded frames are GPU surfaces. CPU filters need hwdownload first.
|
||||
if use_hw_vaapi:
|
||||
if filters:
|
||||
@@ -119,6 +130,12 @@ def build_ffmpeg_command(
|
||||
if filters:
|
||||
cmd += ["-vf", ",".join(filters)]
|
||||
|
||||
# LTX-2 output rate + exact frame cap (apply to both clip and webp-seq paths).
|
||||
if target_fps is not None:
|
||||
cmd += ["-r", f"{target_fps:g}"]
|
||||
if frames is not None:
|
||||
cmd += ["-frames:v", str(frames)]
|
||||
|
||||
if image_sequence:
|
||||
cmd += [
|
||||
"-an",
|
||||
@@ -141,20 +158,66 @@ def build_ffmpeg_command(
|
||||
return cmd
|
||||
|
||||
|
||||
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str) -> list[str]:
|
||||
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str,
|
||||
duration: float = 8.0) -> 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",
|
||||
"-t", str(duration),
|
||||
"-vn",
|
||||
"-c:a", "pcm_s16le",
|
||||
audio_path,
|
||||
]
|
||||
|
||||
|
||||
# Audio codec chosen per output extension for the manual "Extract audio area"
|
||||
# tool. Empty list -> let ffmpeg pick a default encoder from the extension.
|
||||
_AUDIO_CODEC_BY_EXT: dict[str, list[str]] = {
|
||||
".wav": ["-c:a", "pcm_s16le"],
|
||||
".flac": ["-c:a", "flac"],
|
||||
".mp3": ["-c:a", "libmp3lame", "-q:a", "2"],
|
||||
".m4a": ["-c:a", "aac", "-b:a", "256k"],
|
||||
".aac": ["-c:a", "aac", "-b:a", "256k"],
|
||||
".ogg": ["-c:a", "libvorbis", "-q:a", "5"],
|
||||
".opus": ["-c:a", "libopus", "-b:a", "192k"],
|
||||
}
|
||||
|
||||
|
||||
def probe_duration(path: str) -> float | None:
|
||||
"""Return the media duration in seconds via ffprobe, or None on failure."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[_bin("ffprobe"), "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=nw=1:nk=1", path],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
return float(r.stdout.strip())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def build_audio_clip_command(input_path: str, start: float, duration: float,
|
||||
out_path: str) -> list[str]:
|
||||
"""ffmpeg command to extract exactly *duration* seconds of audio starting
|
||||
at *start*, re-encoded per *out_path*'s extension (wav/mp3/flac/…)."""
|
||||
ext = os.path.splitext(out_path)[1].lower()
|
||||
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
|
||||
return [
|
||||
_bin("ffmpeg"), "-y",
|
||||
"-ss", str(start),
|
||||
"-i", input_path,
|
||||
"-t", str(duration),
|
||||
"-vn",
|
||||
*codec,
|
||||
out_path,
|
||||
]
|
||||
|
||||
|
||||
def detect_hw_encoders() -> list[str]:
|
||||
"""Probe ffmpeg for available H.264 hardware encoders.
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""LTX-2 frame-count math. Legal F satisfy F % 8 == 1 (8x temporal + 1)."""
|
||||
|
||||
|
||||
def is_legal_frames(f: int) -> bool:
|
||||
return f >= 9 and f % 8 == 1
|
||||
|
||||
|
||||
def legal_frames(min_f: int = 9, max_f: int = 1000) -> list[int]:
|
||||
start = max(9, min_f + ((1 - min_f) % 8)) # first 8k+1 >= min_f
|
||||
return list(range(start, max_f + 1, 8))
|
||||
|
||||
|
||||
def nearest_legal_frames(f: int) -> int:
|
||||
if f <= 9:
|
||||
return 9
|
||||
low = ((f - 1) // 8) * 8 + 1
|
||||
high = low + 8
|
||||
return low if (f - low) <= (high - f) else high
|
||||
|
||||
|
||||
def duration_for_frames(frames: int, fps: float) -> float:
|
||||
return frames / fps
|
||||
|
||||
|
||||
def frames_for_duration(duration: float, fps: float) -> int:
|
||||
return nearest_legal_frames(round(duration * fps))
|
||||
@@ -0,0 +1,130 @@
|
||||
# Main Window UI Restructure — Design
|
||||
|
||||
**Goal:** Reorganize the `MainWindow` UI in `main.py` from a flat wall of ~50 always-visible controls into a legible, grouped layout — a menu bar for rare actions, a tabbed control deck for settings, an always-visible transport bar, and a real status bar — plus a visual polish pass. Keep every existing behavior, shortcut, and mouse interaction working.
|
||||
|
||||
**Scope:** Reorganization **and** visual polish. **Not** an interaction-model change — single-key shortcuts, timeline mouse overloading, and the export/scan logic are untouched.
|
||||
|
||||
**Audience:** Single power user. Optimize for density and speed. The goal is *order, not hiding*: keep everything fast to reach; push only genuinely rare actions into menus.
|
||||
|
||||
**Runs in:** Python/Qt client (`main.py`), `MainWindow` class only. No `core/` changes.
|
||||
|
||||
---
|
||||
|
||||
## Problem (from audit)
|
||||
|
||||
- **No information architecture.** No menu bar, no toolbar; status bar explicitly disabled (`setStatusBar(None)`, main.py:4440). Every function is a permanently-visible widget at equal weight.
|
||||
- **`settings_row` overloaded** (main.py:4334–4370): 24 widgets in one non-wrapping `QHBoxLayout` spanning three unrelated domains (encode/clip params, export variants, audio-scan ML). Needs >1500px; window opens at 1100px.
|
||||
- **Stranded controls** — e.g. the workers spinbox sits between Cancel and Delete in the transport row (main.py:4316).
|
||||
- **Weak feedback** — only an 11px `#888` status label at the far-right end of the overflowing settings row (main.py:4364).
|
||||
- **Flat visual hierarchy** — single Fusion stylesheet, scattered inline `setStyleSheet` state swaps, no primary/secondary distinction, no grouping.
|
||||
|
||||
---
|
||||
|
||||
## Chosen approach: Tabbed control deck
|
||||
|
||||
The 3-pane horizontal splitter (Queue · Center · Scan results) is unchanged. The center column is restructured:
|
||||
|
||||
```
|
||||
╔═ File Edit Scan View Help ═══════════════════ Profile:[default▾] [?] ╗ menu bar (+ corner widgets)
|
||||
║ ┌Queue──┐ │ current_file.mp4 │ ┌ Scan results ─────┐ ║
|
||||
║ │+Open │ │ ┌──────────────────────────────────────┐ │ │ [model tabs] │ ║
|
||||
║ │filter │ │ │ VIDEO (mpv) │ │ │ version▾ │ ║
|
||||
║ │┌List┬+┐│ │ │ │ │ │ start end score │ ║
|
||||
║ ││f1 ││ │ │ └──────────────────────────────────────┘ │ │ ... │ ║
|
||||
║ ││f2 ││ │ │ [════════════ timeline ════════════════] │ │ │ ║
|
||||
║ │└────┘ ││ │ [════════════ crop bar ════════════════] │ │ [Neg] [Export] │ ║
|
||||
║ └───────┘ │ ┌─ transport (always visible) ──────────┐ │ └───────────────────┘ ║
|
||||
║ │ │▶ ⏸ x2 x4 🔒 --/-- ··· [Export] +₁+₂ Cancel Delete│ ║
|
||||
║ │ ├─[ Export ]─[ Crop & Track ]─[ Scan ]──┤ ← control deck (tabs) ║
|
||||
║ │ │ (controls for the active tab here) │ ║
|
||||
║ │ └───────────────────────────────────────┘ ║
|
||||
╠═══════════════════════════════════════════════════════════════════════════════╣
|
||||
║ Ready. current file · profile: default · 8 wk ║ status bar
|
||||
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
**Why tabbed deck:** Replaces the three stacked rows with a compact tab strip. The transport bar (most-used controls) stays always visible above the tabs; settings group by concern behind tabs. Trade-off accepted: viewing Scan + Export controls simultaneously costs a tab switch.
|
||||
|
||||
---
|
||||
|
||||
## Control mapping
|
||||
|
||||
Every current control has an explicit home; nothing is removed.
|
||||
|
||||
### Menu bar (rare / batch / management)
|
||||
|
||||
| Menu | Items |
|
||||
|------|-------|
|
||||
| **File** | Open Files… · Set export folder… · Quit |
|
||||
| **Edit** | Undo *(Ctrl+Z → `_scan_panel.undo`)* · Subprofiles ▸ (Add… / Remove…) |
|
||||
| **Scan** | Scan current · Auto-export · Scan All… · Train classifier… |
|
||||
| **View** | Review mode ✓ · Subcategory markers ▸ · Hide exported ✓ · Show hidden ✓ |
|
||||
| **Help** | Keyboard shortcuts *(? / F1)* · What's new · About |
|
||||
| *corner (right)* | Profile ▾ · `?` |
|
||||
|
||||
*Hard Negatives and Dataset Stats remain inside the Train dialog (main.py:682, 762) — not surfaced separately. Profile new/delete remains driven by the profile combo's `activated` handler.*
|
||||
|
||||
### Transport bar (always visible — playback + one-press export actions)
|
||||
|
||||
`▶ Play · ⏸ Pause · x2 · x4 · 🔒 Lock · --/-- time · ⟨stretch⟩ · next-preview · **Export** · subprofile buttons ₁₂… · Cancel · Delete`
|
||||
|
||||
### Control deck — Export tab
|
||||
`Label · Category · Name · Folder + browse · Format · HW encode · Resize · Duration · Clips · Spread · Workers · Re-export`
|
||||
|
||||
### Control deck — Crop & Track tab
|
||||
`Portrait ratio · 1 random portrait · 1 random square · Track subject`
|
||||
|
||||
### Control deck — Scan tab
|
||||
`Scan model ▾ · ⏲ history · Scan · Auto · Speech · Review · Fuse · Threshold`
|
||||
|
||||
### Left pane (Queue) — unchanged
|
||||
`+ Open · filter · Hide exported · Show hidden · list tabs (tabbed / side-by-side)`
|
||||
|
||||
### Right pane (Scan results) — unchanged structurally
|
||||
|
||||
### Decisions
|
||||
- **Train** → Scan menu only (no deck button).
|
||||
- **Subcategory markers ("Sub")** → View menu submenu (off the deck).
|
||||
- Items appearing in both a menu and a visible control (Hide exported, Review, Scan, Auto) share one handler and stay synced.
|
||||
|
||||
---
|
||||
|
||||
## Status bar
|
||||
|
||||
Restores `QStatusBar` (removes `setStatusBar(None)`):
|
||||
- **Left**: transient feedback — `Exporting 2/3…`, `Scan complete · 14 regions`, `Ready.` — with an optional inline `QProgressBar` for export/scan runs. Replaces `_lbl_status` and the `_status_timer` clear logic.
|
||||
- **Right (permanent widget)**: `current file · profile: <name> · <n> workers`.
|
||||
|
||||
---
|
||||
|
||||
## Visual polish
|
||||
|
||||
Extends the existing dark Fusion theme — no theme change.
|
||||
|
||||
1. **Aligned tab layouts** — each deck tab uses `QFormLayout`/grid so `label : control` pairs align in columns (biggest legibility win vs. today's ragged horizontal runs).
|
||||
2. **Primary/secondary button weight** — **Export** gets an accent style (blue, reusing `#3a6ea8`); Cancel/Delete read as secondary/destructive. The existing **red Export = "armed to overwrite"** state (main.py:5403) is preserved as a distinct state layered on top.
|
||||
3. **Consistent toggle states** — x2 / x4 / 🔒 Lock / Review are checkable; one global `:checked` style replaces Lock's ad-hoc inline `#4a3000` swap (main.py:5705).
|
||||
4. **Spacing rhythm** — uniform margins/spacing; **fixed deck height** (= tallest tab) so the video never resizes on tab switch.
|
||||
5. **Label cleanup** — de-abbreviate where cheap (`Thr→Threshold`, `Dur→Duration`); replace cryptic `⏲` with a clearer history affordance.
|
||||
6. **One stylesheet block** — fold scattered inline `setStyleSheet` calls into the central sheet (tabs, separators, status bar, toggles, primary button); keep per-widget overrides only for genuine state changes (overwrite-armed Export).
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes & risks
|
||||
|
||||
- **Preserve all signal wiring.** Controls are re-parented into new layouts, but every existing `connect()` and the controls' object identities are kept — this is a layout move, not a rewrite of handlers.
|
||||
- **Preserve all shortcuts.** The `QShortcut` block (main.py:4450–4483) and `_KeyFilter` focus suppression are untouched. Menu items reuse the same handler methods and may display the matching shortcut text.
|
||||
- **Fixed deck height** prevents video-area jump when switching tabs.
|
||||
- **Synced menu/button state** — checkable menu items (Review, Hide exported) and their visible toggles must reflect each other; route both through the existing handler and update both widgets.
|
||||
- **Profile combo** moves to a menu-bar corner widget but keeps its existing `activated` → new/delete/switch logic intact.
|
||||
- Risk: re-parenting a large `__init__` is error-prone. Mitigate by moving controls in small, independently-runnable stages (menu bar → status bar → deck tabs → transport bar → polish), launching the app after each.
|
||||
|
||||
---
|
||||
|
||||
## What this does NOT do
|
||||
|
||||
- No change to export, scan, tracking, or DB logic — `core/` untouched.
|
||||
- No change to keyboard shortcuts or timeline mouse interactions.
|
||||
- No theme change — stays dark Fusion.
|
||||
- No new features — every control already exists; this is rehousing + polish.
|
||||
- No change to the Queue or Scan-results panes' internal structure.
|
||||
@@ -0,0 +1,547 @@
|
||||
# Main Window UI Restructure — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Re-house `MainWindow`'s ~50 flat controls into a menu bar (rare actions), an always-visible transport bar, a 3-tab control deck (Export / Crop & Track / Scan), and a real status bar — then a visual-polish pass — without changing any behavior, shortcut, or `core/` logic.
|
||||
|
||||
**Architecture:** Pure layout reorganization inside `main.py`'s `MainWindow`. Existing widget objects and every `connect()` are **preserved and re-parented**, not recreated. The monster `__init__` is incrementally broken into `_build_*` helper methods (stays single-file — matches the project's architecture). Companion design doc: `docs/plans/2026-06-13-ui-restructure-design.md`.
|
||||
|
||||
**Tech Stack:** Python 3.11+, PyQt6, pytest. App entry: `main.py`; launch via `./8cut.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Conventions for every task
|
||||
|
||||
- **Line references drift** as edits land. Always locate by the named symbol (method/variable), not the line number alone. Numbers are the *starting* anchors as of this plan.
|
||||
- **Authoritative verification is a manual launch.** After each task, run `./8cut.sh`, load a video, and confirm the task's controls work AND prior behavior is intact (play, scrub, export, scan). Use the `verify` skill for structured manual checks.
|
||||
- **Structure test is the safety net.** `tests/test_ui_structure.py` (built in Task 0.2) constructs `MainWindow` and asserts containment invariants. It **skips gracefully** if construction fails (e.g. no GL for `MpvWidget` in headless CI), so it never blocks `core/` tests. Run with a display: `pytest tests/test_ui_structure.py -v`.
|
||||
- **Commit after every task.** Small, reversible commits. Commit message convention matches the repo (`feat:`/`fix:`/`refactor:`/`change:`).
|
||||
- **Do not touch** `core/`, export/scan/tracking logic, the `QShortcut` block (around main.py:4450–4483), `_KeyFilter`, or `TimelineWidget` mouse handling.
|
||||
|
||||
---
|
||||
|
||||
## Stage 0 — Branch & safety net
|
||||
|
||||
### Task 0.1: Create a working branch
|
||||
|
||||
**Step 1:** Confirm clean intent and branch off `master`:
|
||||
```bash
|
||||
git switch -c ui-restructure
|
||||
```
|
||||
**Step 2:** Verify: `git branch --show-current` → `ui-restructure`.
|
||||
(The repo has pre-existing untracked/modified files; leave them alone — they are not part of this work.)
|
||||
|
||||
### Task 0.2: Add the structure-test safety net
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_ui_structure.py`
|
||||
|
||||
**Step 1: Write the test harness + baseline invariant**
|
||||
|
||||
```python
|
||||
import os
|
||||
import pytest
|
||||
|
||||
# A real platform is needed because MpvWidget creates a GL context.
|
||||
# If construction fails for any environment reason, skip — this test is a
|
||||
# best-effort structural net, not a gate on core/ tests.
|
||||
pytestmark = pytest.mark.gui
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
inst = QApplication.instance() or QApplication([])
|
||||
yield inst
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def win(app):
|
||||
try:
|
||||
from main import MainWindow
|
||||
w = MainWindow()
|
||||
except Exception as e: # GL/mpv/display unavailable, etc.
|
||||
pytest.skip(f"MainWindow could not be constructed here: {e}")
|
||||
yield w
|
||||
w.close()
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def _descendant_object_names(widget):
|
||||
"""All objectNames in a widget's child tree (for containment asserts)."""
|
||||
return {c.objectName() for c in widget.findChildren(object) if c.objectName()}
|
||||
|
||||
|
||||
def test_window_constructs(win):
|
||||
assert win.windowTitle() == "8-cut"
|
||||
```
|
||||
|
||||
**Step 2: Run it**
|
||||
|
||||
Run: `pytest tests/test_ui_structure.py -v`
|
||||
Expected: `test_window_constructs` PASSES (with a display) or SKIPS (headless). Either is acceptable — it must not ERROR.
|
||||
|
||||
**Step 3:** Register the `gui` marker to silence warnings.
|
||||
|
||||
Modify `conftest.py` — append:
|
||||
```python
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "gui: constructs Qt widgets; needs a display")
|
||||
```
|
||||
|
||||
**Step 4: Confirm core tests still pass**
|
||||
|
||||
Run: `pytest tests/test_utils.py tests/test_db.py -q`
|
||||
Expected: PASS (unchanged).
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add tests/test_ui_structure.py conftest.py
|
||||
git commit -m "test: add MainWindow structure smoke test (skips headless)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stage 1 — Menu bar
|
||||
|
||||
Add a `QMenuBar` whose actions reuse existing handler methods. Move the profile combo and `?` button into menu-bar corner widgets. Keep the original buttons that also live elsewhere (Scan, Auto) — menus and buttons share handlers.
|
||||
|
||||
### Task 1.1: Extract a `_build_menubar()` and add the five menus
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.py` `MainWindow.__init__` (call site) and add method `_build_menubar`
|
||||
|
||||
**Step 1:** Add the method (place near other `_build`/setup helpers, e.g. after `__init__`). Wire each action to the **existing** handler method:
|
||||
|
||||
```python
|
||||
def _build_menubar(self) -> None:
|
||||
from PyQt6.QtGui import QAction
|
||||
mb = self.menuBar()
|
||||
|
||||
# File
|
||||
m_file = mb.addMenu("&File")
|
||||
m_file.addAction("Open Files…", self._on_open_files)
|
||||
m_file.addAction("Set export folder…", self._pick_folder)
|
||||
m_file.addSeparator()
|
||||
m_file.addAction("Quit", self.close)
|
||||
|
||||
# Edit
|
||||
m_edit = mb.addMenu("&Edit")
|
||||
self._act_undo = m_edit.addAction("Undo scan edit", self._scan_panel.undo)
|
||||
self._act_undo.setShortcut("Ctrl+Z")
|
||||
m_edit.addSeparator()
|
||||
m_subs = m_edit.addMenu("Subprofiles")
|
||||
m_subs.addAction("Add…", self._new_subprofile)
|
||||
self._menu_subprofiles_remove = m_subs.addMenu("Remove")
|
||||
self._rebuild_remove_subprofile_menu() # built in Task 4.x
|
||||
|
||||
# Scan
|
||||
m_scan = mb.addMenu("&Scan")
|
||||
m_scan.addAction("Scan current", self._start_scan)
|
||||
m_scan.addAction("Auto-export", self._auto_export)
|
||||
m_scan.addSeparator()
|
||||
m_scan.addAction("Scan All…", self._start_scan_all)
|
||||
m_scan.addAction("Train classifier…", self._open_train_dialog)
|
||||
|
||||
# View
|
||||
m_view = mb.addMenu("&View")
|
||||
self._act_review = m_view.addAction("Review mode")
|
||||
self._act_review.setCheckable(True)
|
||||
self._act_review.toggled.connect(self._btn_scan_mode.setChecked)
|
||||
m_view.addAction("Subcategory markers…", self._show_subcat_menu)
|
||||
m_view.addSeparator()
|
||||
self._act_hide_exported = m_view.addAction("Hide exported")
|
||||
self._act_hide_exported.setCheckable(True)
|
||||
self._act_hide_exported.toggled.connect(self._chk_hide_exported.setChecked)
|
||||
self._chk_hide_exported.toggled.connect(self._act_hide_exported.setChecked)
|
||||
self._act_show_hidden = m_view.addAction("Show hidden")
|
||||
self._act_show_hidden.setCheckable(True)
|
||||
self._act_show_hidden.toggled.connect(self._btn_show_hidden.setChecked)
|
||||
self._btn_show_hidden.toggled.connect(self._act_show_hidden.setChecked)
|
||||
|
||||
# Help
|
||||
m_help = mb.addMenu("&Help")
|
||||
m_help.addAction("Keyboard shortcuts", self._show_shortcuts).setShortcut("F1")
|
||||
m_help.addAction("What's new", self._show_changelog)
|
||||
m_help.addAction("About", self._show_about) # tiny method, Task 1.3
|
||||
```
|
||||
|
||||
> **Sync note:** `QAction.toggled`/`QAbstractButton.toggled` do not re-emit when the value is unchanged, so the bidirectional `setChecked` connections (Review, Hide exported, Show hidden) cannot loop. `_btn_scan_mode` → `_act_review` reverse sync is added in Task 3.4 once the button is in the Scan tab.
|
||||
|
||||
**Step 2:** Stub the two small new methods referenced above:
|
||||
```python
|
||||
def _show_about(self) -> None:
|
||||
QMessageBox.about(self, "About 8-cut",
|
||||
f"<b>8-cut</b> v{self.APP_VERSION}<br>"
|
||||
"8-second clips for foley datasets.")
|
||||
|
||||
def _rebuild_remove_subprofile_menu(self) -> None:
|
||||
self._menu_subprofiles_remove.clear()
|
||||
for name in self._subprofiles:
|
||||
self._menu_subprofiles_remove.addAction(
|
||||
name, lambda _=False, n=name: self._remove_subprofile(n))
|
||||
self._menu_subprofiles_remove.setEnabled(bool(self._subprofiles))
|
||||
```
|
||||
|
||||
**Step 3:** Call `self._build_menubar()` in `__init__`, **after** `self._scan_panel` and all referenced buttons exist (i.e. just before/after the splitter assembly around main.py:4429). The scan panel is created at main.py:4414, so place the call after that.
|
||||
|
||||
**Step 4 (manual verify):** `./8cut.sh` → menu bar shows File/Edit/Scan/View/Help; each item triggers its action; Ctrl+Z still undoes scan edits; F1 shows shortcuts.
|
||||
|
||||
**Step 5:** Commit: `feat: add menu bar wired to existing handlers`.
|
||||
|
||||
### Task 1.2: Move profile combo + `?` into menu-bar corner
|
||||
|
||||
**Files:** Modify `main.py` — `top_bar` assembly (around main.py:4290–4294) and `_build_menubar`.
|
||||
|
||||
**Step 1:** Remove `self._cmb_profile` and `self._btn_shortcuts` (and the `"Profile:"` `QLabel`) from `top_bar`. Keep `self._lbl_file` in `top_bar` (it stays as the slim filename header above the video).
|
||||
|
||||
**Step 2:** In `_build_menubar`, set a corner widget:
|
||||
```python
|
||||
from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel
|
||||
corner = QWidget()
|
||||
ch = QHBoxLayout(corner)
|
||||
ch.setContentsMargins(0, 0, 6, 0)
|
||||
ch.addWidget(QLabel("Profile:"))
|
||||
ch.addWidget(self._cmb_profile)
|
||||
ch.addWidget(self._btn_shortcuts)
|
||||
mb.setCornerWidget(corner, Qt.Corner.TopRightCorner)
|
||||
```
|
||||
(Build the corner widget at the end of `_build_menubar`, after `self._cmb_profile` exists — it is created at main.py:4272.)
|
||||
|
||||
**Step 3 (manual verify):** Profile dropdown works (switch/new/delete); `?` opens shortcuts; filename still shows above the video.
|
||||
|
||||
**Step 4:** Commit: `change: move profile selector and help into menu-bar corner`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 2 — Status bar
|
||||
|
||||
### Task 2.1: Restore `QStatusBar` and route `_show_status` to it
|
||||
|
||||
**Files:** Modify `main.py` — `__init__` (`setStatusBar(None)` at main.py:4440, `_lbl_status`/`_status_timer` at main.py:4364–4370) and `_show_status` (main.py:5065).
|
||||
|
||||
**Step 1:** Replace `self.setStatusBar(None)` with a real status bar built in a helper:
|
||||
```python
|
||||
def _build_status_bar(self) -> None:
|
||||
sb = self.statusBar()
|
||||
self._status_perm = QLabel("")
|
||||
self._status_perm.setStyleSheet("color: #888;")
|
||||
sb.addPermanentWidget(self._status_perm)
|
||||
self._update_status_perm()
|
||||
|
||||
def _update_status_perm(self) -> None:
|
||||
name = os.path.basename(self._file_path) if self._file_path else "—"
|
||||
self._status_perm.setText(
|
||||
f"{name} · profile: {self._profile()} · {self._spn_workers.value()} workers")
|
||||
```
|
||||
Call `self._build_status_bar()` in `__init__` near the menubar call.
|
||||
|
||||
**Step 2:** Rewrite `_show_status` to use the status bar (this subsumes `_status_timer`):
|
||||
```python
|
||||
def _show_status(self, msg: str, timeout: int = 0) -> None:
|
||||
"""Show a transient message in the status bar. timeout in ms (0 = sticky)."""
|
||||
self.statusBar().showMessage(msg, timeout)
|
||||
```
|
||||
|
||||
**Step 3:** Delete `self._lbl_status`, `self._status_timer`, and `settings_row.addWidget(self._lbl_status)` (main.py:4364–4370). Remove the `_status_timer.timeout` connection.
|
||||
|
||||
**Step 4:** Keep `_update_status_perm()` fresh — call it where file/profile/workers change: end of `_after_load`, in `_on_profile_activated`, and in the `_spn_workers.valueChanged` lambda.
|
||||
|
||||
**Step 5 (manual verify):** Start an export → status text appears bottom-left and auto-clears; bottom-right shows file · profile · workers and updates on file/profile/worker change.
|
||||
|
||||
**Step 6:** Commit: `feat: real status bar replaces inline status label`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 3 — Control deck (the core move)
|
||||
|
||||
Build a fixed-height `QTabWidget` with three tab pages, then **re-parent** the existing controls from `path_row` and `settings_row` into them. Give each page an `objectName` for the structure test. Do tabs one at a time so the app stays runnable.
|
||||
|
||||
### Task 3.1: Build the empty deck and mount it
|
||||
|
||||
**Files:** Modify `main.py` — `right_layout` assembly (main.py:4372–4382).
|
||||
|
||||
**Step 1:** Add a helper that creates the deck and three empty pages:
|
||||
```python
|
||||
def _build_control_deck(self) -> "QTabWidget":
|
||||
from PyQt6.QtWidgets import QTabWidget, QWidget
|
||||
deck = QTabWidget()
|
||||
deck.setObjectName("control_deck")
|
||||
deck.setDocumentMode(True)
|
||||
self._tab_export = QWidget(); self._tab_export.setObjectName("export_tab")
|
||||
self._tab_crop = QWidget(); self._tab_crop.setObjectName("crop_tab")
|
||||
self._tab_scan = QWidget(); self._tab_scan.setObjectName("scan_tab")
|
||||
deck.addTab(self._tab_export, "Export")
|
||||
deck.addTab(self._tab_crop, "Crop && Track")
|
||||
deck.addTab(self._tab_scan, "Scan")
|
||||
self._control_deck = deck
|
||||
return deck
|
||||
```
|
||||
|
||||
**Step 2:** In `right_layout`, **keep** `transport_row` for now, but replace the `path_row` and `settings_row` additions with the deck:
|
||||
- Remove `right_layout.addLayout(path_row)` and `right_layout.addLayout(settings_row)`.
|
||||
- Add `right_layout.addWidget(self._build_control_deck())`.
|
||||
- Leave the `path_row`/`settings_row` *construction* in place for this task (the widgets are still parented to nothing visible) — they get moved into tabs in 3.2–3.4. **App is briefly missing those controls between 3.1 and 3.4; that's expected mid-stage.**
|
||||
|
||||
**Step 3 (manual verify):** App launches; three empty tabs appear under the transport bar; switching tabs doesn't resize the video (height fixed in Task 3.5).
|
||||
|
||||
**Step 4:** Commit: `refactor: add empty 3-tab control deck under transport`.
|
||||
|
||||
### Task 3.2: Populate the Export tab
|
||||
|
||||
**Files:** Modify `main.py` — move widgets from `path_row` (main.py:4322–4331) and the encode/clip parts of `settings_row` (main.py:4334–4348) plus `_spn_workers` (main.py:4213).
|
||||
|
||||
**Step 1:** Build the Export tab with an aligned grid:
|
||||
```python
|
||||
def _build_export_tab(self) -> None:
|
||||
from PyQt6.QtWidgets import QGridLayout, QLabel, QHBoxLayout
|
||||
g = QGridLayout(self._tab_export)
|
||||
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
|
||||
# Row 0: annotation
|
||||
g.addWidget(QLabel("Label:"), 0, 0); g.addWidget(self._txt_label, 0, 1)
|
||||
g.addWidget(QLabel("Cat:"), 0, 2); g.addWidget(self._cmb_category, 0, 3)
|
||||
g.addWidget(QLabel("Name:"), 0, 4); g.addWidget(self._txt_name, 0, 5)
|
||||
# Row 1: output path
|
||||
folder_row = QHBoxLayout()
|
||||
folder_row.addWidget(self._txt_folder, 1); folder_row.addWidget(self._btn_folder)
|
||||
g.addWidget(QLabel("Folder:"), 1, 0); g.addLayout(folder_row, 1, 1, 1, 5)
|
||||
# Row 2: encode / clip params
|
||||
g.addWidget(QLabel("Format:"), 2, 0); g.addWidget(self._cmb_format, 2, 1)
|
||||
g.addWidget(self._chk_hw, 2, 2)
|
||||
g.addWidget(QLabel("Resize:"), 2, 3); g.addWidget(self._spn_resize, 2, 4)
|
||||
# Row 3: batch params + actions
|
||||
g.addWidget(QLabel("Duration:"), 3, 0); g.addWidget(self._spn_clip_dur, 3, 1)
|
||||
g.addWidget(QLabel("Clips:"), 3, 2); g.addWidget(self._spn_clips, 3, 3)
|
||||
g.addWidget(QLabel("Spread:"), 3, 4); g.addWidget(self._spn_spread, 3, 5)
|
||||
g.addWidget(QLabel("Workers:"), 4, 0); g.addWidget(self._spn_workers, 4, 1)
|
||||
g.addWidget(self._btn_reexport, 4, 5)
|
||||
```
|
||||
Call it from `_build_control_deck` (or right after, in `__init__`).
|
||||
|
||||
**Step 2:** Delete the now-duplicate `addWidget` calls for these widgets from `path_row` and `settings_row` construction. (Re-parenting via `addWidget` into the grid auto-removes them from the old layout, but remove the dead lines to keep `__init__` honest.)
|
||||
|
||||
**Step 3 (manual verify):** Export tab shows aligned Label/Cat/Name, Folder+browse, Format/HW/Resize, Duration/Clips/Spread/Workers/Re-export. Change each → still persists to `QSettings` and updates the timeline span / next-label as before. Export still works (E).
|
||||
|
||||
**Step 4:** Commit: `refactor: move export & encode controls into Export tab`.
|
||||
|
||||
### Task 3.3: Populate the Crop & Track tab
|
||||
|
||||
**Files:** Modify `main.py` — move `_cmb_portrait`, `_chk_rand_portrait`, `_chk_rand_square`, `_chk_track` from `settings_row` (main.py:4337, 4349–4351).
|
||||
|
||||
**Step 1:**
|
||||
```python
|
||||
def _build_crop_tab(self) -> None:
|
||||
from PyQt6.QtWidgets import QGridLayout, QLabel
|
||||
g = QGridLayout(self._tab_crop)
|
||||
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
|
||||
g.addWidget(QLabel("Portrait:"), 0, 0); g.addWidget(self._cmb_portrait, 0, 1)
|
||||
g.addWidget(self._chk_rand_portrait, 1, 0, 1, 2)
|
||||
g.addWidget(self._chk_rand_square, 2, 0, 1, 2)
|
||||
g.addWidget(self._chk_track, 3, 0, 1, 2)
|
||||
g.setRowStretch(4, 1); g.setColumnStretch(2, 1)
|
||||
```
|
||||
|
||||
**Step 2:** Remove those four widgets' old `settings_row.addWidget` lines.
|
||||
|
||||
**Step 3 (manual verify):** Crop & Track tab shows the four controls; portrait ratio still toggles the crop overlay/crop-bar; random/track checkboxes persist.
|
||||
|
||||
**Step 4:** Commit: `refactor: move crop & track controls into their tab`.
|
||||
|
||||
### Task 3.4: Populate the Scan tab (and drop menu-only buttons)
|
||||
|
||||
**Files:** Modify `main.py` — move scan widgets from `settings_row` (main.py:4352–4362). Buttons that became **menu-only** (Train, Scan All, Sub) are NOT added to the tab and are deleted.
|
||||
|
||||
**Step 1:**
|
||||
```python
|
||||
def _build_scan_tab(self) -> None:
|
||||
from PyQt6.QtWidgets import QGridLayout, QLabel, QHBoxLayout
|
||||
g = QGridLayout(self._tab_scan)
|
||||
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
|
||||
model_row = QHBoxLayout()
|
||||
model_row.addWidget(self._cmb_scan_model, 1); model_row.addWidget(self._btn_model_history)
|
||||
g.addWidget(QLabel("Model:"), 0, 0); g.addLayout(model_row, 0, 1, 1, 3)
|
||||
g.addWidget(self._btn_scan, 1, 0); g.addWidget(self._btn_auto_export, 1, 1)
|
||||
g.addWidget(self._btn_speech, 1, 2); g.addWidget(self._btn_scan_mode, 1, 3)
|
||||
g.addWidget(self._spn_auto_fuse, 2, 0); g.addWidget(self._sld_threshold, 2, 1)
|
||||
g.setColumnStretch(3, 1)
|
||||
```
|
||||
|
||||
**Step 2:** Reverse-sync Review with the View menu (the forward sync was added in Task 1.1):
|
||||
```python
|
||||
self._btn_scan_mode.toggled.connect(self._act_review.setChecked)
|
||||
```
|
||||
Add this right after `_build_scan_tab` runs (both `_btn_scan_mode` and `_act_review` exist by then).
|
||||
|
||||
**Step 3:** Delete the menu-only buttons and their `settings_row` lines: `self._btn_train` (main.py:4167–4170), `self._btn_scan_all` (main.py:4172–4174), `self._btn_hide_subcats` (main.py:4154–4157). Their handlers (`_open_train_dialog`, `_start_scan_all`, `_show_subcat_menu`) stay — now reached via menus.
|
||||
|
||||
**Step 4:** Re-anchor `_show_subcat_menu` (main.py:5989) so it no longer depends on the deleted `_btn_hide_subcats`:
|
||||
```python
|
||||
# was: self._btn_hide_subcats.mapToGlobal(self._btn_hide_subcats.rect().bottomLeft())
|
||||
from PyQt6.QtGui import QCursor
|
||||
menu.exec(QCursor.pos())
|
||||
```
|
||||
Apply to **both** `exec` call sites in that method.
|
||||
|
||||
**Step 5 (manual verify):** Scan tab shows Model+history, Scan/Auto/Speech/Review, Fuse/Threshold. `Scan` runs; `Review` toggles and stays in sync with View ▸ Review mode (both directions); View ▸ Subcategory markers… opens the full popup near the cursor; Scan ▸ Scan All / Train still work.
|
||||
|
||||
**Step 6:** Commit: `refactor: move scan controls into Scan tab; Train/ScanAll/Sub to menus`.
|
||||
|
||||
### Task 3.5: Fix deck height; remove dead `path_row`/`settings_row`
|
||||
|
||||
**Files:** Modify `main.py` — `__init__`.
|
||||
|
||||
**Step 1:** The `path_row`/`settings_row` `QHBoxLayout`s should now be empty. Delete their construction blocks entirely (main.py:4321–4370 minus what was already removed), including the `self._transport_row = transport_row` line only if unused elsewhere (it IS used by `_rebuild_subprofile_buttons` — keep `transport_row`).
|
||||
|
||||
**Step 2:** Pin the deck height so tab switches don't move the video:
|
||||
```python
|
||||
self._control_deck.setFixedHeight(self._control_deck.sizeHint().height())
|
||||
```
|
||||
Call after all three tabs are built. If the tallest tab (Export, 5 rows) clips, set an explicit value instead (e.g. `setFixedHeight(150)`); confirm visually.
|
||||
|
||||
**Step 3 (manual verify):** Switching Export↔Crop↔Scan keeps the video size constant; no clipped controls; all three tabs fully usable.
|
||||
|
||||
**Step 4:** Commit: `refactor: fix control-deck height; drop dead settings rows`.
|
||||
|
||||
### Task 3.6: Extend the structure test for the deck
|
||||
|
||||
**Files:** Modify `tests/test_ui_structure.py`.
|
||||
|
||||
**Step 1:** Add invariants:
|
||||
```python
|
||||
def test_menubar_has_expected_menus(win):
|
||||
titles = [m.title().replace("&", "") for m in win.menuBar().findChildren(type(win.menuBar().addMenu("")))]
|
||||
for expected in ("File", "Edit", "Scan", "View", "Help"):
|
||||
assert any(expected == t for t in titles)
|
||||
|
||||
def test_status_bar_exists(win):
|
||||
assert win.statusBar() is not None
|
||||
|
||||
def test_workers_spinbox_in_export_tab(win):
|
||||
from PyQt6.QtWidgets import QSpinBox
|
||||
assert win._spn_workers in win._tab_export.findChildren(QSpinBox)
|
||||
|
||||
def test_scan_button_in_scan_tab(win):
|
||||
from PyQt6.QtWidgets import QPushButton
|
||||
assert win._btn_scan in win._tab_scan.findChildren(QPushButton)
|
||||
|
||||
def test_portrait_combo_in_crop_tab(win):
|
||||
from PyQt6.QtWidgets import QComboBox
|
||||
assert win._cmb_portrait in win._tab_crop.findChildren(QComboBox)
|
||||
```
|
||||
(Adjust the menu-title introspection if the helper is awkward; the key invariants are the tab-containment ones.)
|
||||
|
||||
**Step 2:** Run: `pytest tests/test_ui_structure.py -v` → PASS with a display (or SKIP headless).
|
||||
|
||||
**Step 3:** Commit: `test: assert control-deck containment invariants`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Transport bar tidy & subprofile menu sync
|
||||
|
||||
### Task 4.1: Confirm transport bar contents; keep subprofile export buttons inline
|
||||
|
||||
**Files:** Modify `main.py` — `transport_row` (main.py:4296–4319).
|
||||
|
||||
**Step 1:** The workers spinbox was moved in Task 3.2 — confirm `transport_row.addWidget(self._spn_workers)` is gone. Remaining transport order: Play, Pause, x2, x4, Lock, time, stretch, next-label, **Export**, subprofile buttons, `+` (add subprofile), Cancel, Delete. Leave subprofile **export** buttons inline (they carry the 1–9 shortcuts and belong with Export).
|
||||
|
||||
**Step 2:** Keep the inline `+` add-subprofile button, but also ensure the Edit ▸ Subprofiles ▸ Remove submenu is rebuilt whenever subprofiles change. In `_rebuild_subprofile_buttons` (main.py:5530-ish) and after add/remove, call `self._rebuild_remove_subprofile_menu()`.
|
||||
|
||||
**Step 3 (manual verify):** Transport row reads cleanly; adding/removing a subprofile updates both the inline buttons and Edit ▸ Subprofiles ▸ Remove; number keys 1–9 still export to subprofiles.
|
||||
|
||||
**Step 4:** Commit: `change: tidy transport row; sync subprofile remove menu`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 5 — Visual polish
|
||||
|
||||
All Stage 5 verification is **manual** (visual). Take a screenshot before 5.1 for comparison (use the `run`/`verify` skill).
|
||||
|
||||
### Task 5.1: Consolidate the stylesheet (tabs, status bar, toggles, primary button)
|
||||
|
||||
**Files:** Modify `main.py` — global stylesheet in `main()` (main.py:3811–3827).
|
||||
|
||||
**Step 1:** Extend the central sheet (append rules; keep existing ones):
|
||||
```css
|
||||
QTabWidget::pane { border: 1px solid #444; border-radius: 3px; top: -1px; }
|
||||
QTabBar::tab { background: #2a2a2a; color: #bbb; padding: 5px 12px;
|
||||
border: 1px solid #444; border-bottom: none;
|
||||
border-top-left-radius: 3px; border-top-right-radius: 3px; }
|
||||
QTabBar::tab:selected { background: #333; color: #fff; }
|
||||
QPushButton:checked { background: #4a3000; border-color: #ffd230; color: #fff; }
|
||||
QStatusBar { background: #1a1a1a; color: #bbb; }
|
||||
QStatusBar::item { border: none; }
|
||||
QPushButton#primary { background: #3a6ea8; border-color: #4f86c6; color: #fff; }
|
||||
QPushButton#primary:hover { background: #4f86c6; }
|
||||
QMenuBar { background: #1e1e1e; } QMenuBar::item:selected { background: #3a6ea8; }
|
||||
QMenu { background: #2a2a2a; border: 1px solid #555; }
|
||||
QMenu::item:selected { background: #3a6ea8; }
|
||||
```
|
||||
|
||||
**Step 2:** Mark Export primary: `self._btn_export.setObjectName("primary")`.
|
||||
|
||||
**Step 3:** Replace Lock's inline stylesheet swap (main.py:5705) — since `QPushButton:checked` now styles all toggles, delete the two `self._btn_lock.setStyleSheet(...)` lines in `_on_lock_toggled` (keep the rest of the handler).
|
||||
|
||||
**Step 4 (manual verify):** Tabs, menus, status bar, and checked toggles (x2/x4/Lock/Review) all read consistently; Export stands out as primary; Lock still highlights when active.
|
||||
|
||||
**Step 5:** Commit: `style: unify tab/menu/statusbar/toggle styling; mark Export primary`.
|
||||
|
||||
### Task 5.2: Preserve the "armed to overwrite" Export state
|
||||
|
||||
**Files:** Inspect `main.py` — the red-Export swaps (main.py:5403, and the resets at 4960/5211/5447/7170/7199/7218).
|
||||
|
||||
**Step 1:** These set/clear `self._btn_export.setStyleSheet("QPushButton { background: #6a3030; ... }")` to mean "this export will overwrite". With Export now `objectName("primary")`, an empty `setStyleSheet("")` reset reverts to the **primary** look (good). Confirm the armed (red) state still visually overrides primary — inline stylesheet beats the objectName rule, so it does.
|
||||
|
||||
**Step 2 (manual verify):** Select a marker for re-export → Export turns red (armed); deselect → returns to blue primary; export → resets correctly.
|
||||
|
||||
**Step 3:** Commit (only if changes were needed): `fix: keep armed-overwrite Export state over primary style`.
|
||||
|
||||
### Task 5.3: Label cleanup
|
||||
|
||||
**Files:** Modify `main.py` — prefixes/labels.
|
||||
|
||||
**Step 1:** De-abbreviate where free: `_sld_threshold.setPrefix("Threshold: ")` (main.py:4207) → keep short if it overflows the tab; `_spn_auto_fuse` prefix stays `"Fuse: "`. Replace the `⏲` history button text with a tooltip-backed `"History"` or a clearer glyph; keep `setFixedWidth` generous enough.
|
||||
|
||||
**Step 2 (manual verify):** Labels legible; nothing clipped in the Scan tab.
|
||||
|
||||
**Step 3:** Commit: `style: de-abbreviate scan labels`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 6 — Finalize
|
||||
|
||||
### Task 6.1: Full regression pass
|
||||
|
||||
**Step 1 (manual, use `verify` skill):** With a real video loaded, confirm end-to-end: scrub/play/pause/speed/lock; export (E) single + batch + subprofile (1–9); re-export; delete; portrait crop + random + track; scan + auto + speech + review + threshold/fuse; scan-all; train dialog opens; profile switch; queue filter/hide/show-hidden; Ctrl+Z undo; F1/`?` shortcuts.
|
||||
|
||||
**Step 2:** Run `pytest -q` (all suites). Expected: `core/` PASS; `test_ui_structure` PASS (display) or SKIP.
|
||||
|
||||
### Task 6.2: Docs & changelog
|
||||
|
||||
**Files:** Modify `README.md` (UI/shortcuts sections if any references moved) and the in-app `CHANGELOG` list (main.py:4500) — bump `APP_VERSION` and add a "UI restructure" entry so the What's-new dialog announces it.
|
||||
|
||||
**Step 1:** Add changelog entry summarizing: menu bar, tabbed control deck, status bar, visual polish; note all shortcuts unchanged.
|
||||
|
||||
**Step 2:** Commit: `docs: changelog + README for UI restructure`.
|
||||
|
||||
### Task 6.3: Hand off the branch
|
||||
|
||||
**Step 1:** `git log --oneline master..ui-restructure` — review the commit series.
|
||||
**Step 2:** Offer the user: merge to `master`, open a PR, or keep iterating (use `finishing-a-development-branch` skill).
|
||||
|
||||
---
|
||||
|
||||
## Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Re-parenting breaks a `connect()` | Widgets keep identity; only layout membership changes. Manual launch after every task catches breakage immediately. |
|
||||
| Headless test can't build `MpvWidget` | Structure test skips on construction failure; manual launch is authoritative. |
|
||||
| Menu/button state desync (Review, Hide exported) | Bidirectional `setChecked` (no re-emit on equal value → no loop); verified manually in 3.4. |
|
||||
| Subcat popup anchored to deleted button | Re-anchored to `QCursor.pos()` in Task 3.4. |
|
||||
| Deck height jump on tab switch | `setFixedHeight` in Task 3.5. |
|
||||
| Armed-overwrite red Export lost under primary style | Inline stylesheet overrides objectName rule; verified in 5.2. |
|
||||
| Mid-Stage-3 app missing controls | Expected between 3.1–3.4; each sub-task is still committable and launchable. |
|
||||
|
||||
## What this plan does NOT change
|
||||
|
||||
`core/` logic · export/scan/tracking/DB behavior · keyboard shortcuts · timeline mouse interactions · the Queue and Scan-results panes' internals · the dark Fusion theme.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Multi-pane Control Deck — Design + Plan Addendum
|
||||
|
||||
> Addendum to `2026-06-13-ui-restructure-design.md` / `-implementation.md`. Same branch (`ui-restructure`), same constraints (preserve behavior; reorg/feature only; no `core/` changes).
|
||||
|
||||
**Goal:** Let the control-deck panels (Export / Crop & Track / Scan) optionally show **side-by-side as resizable columns** instead of one-at-a-time tabs — mirroring the existing playlist pin→side-by-side pattern.
|
||||
|
||||
> **Revision (post-use, 2026-06-13):** The first implementation showed unpinned panels as a "leftover" tab-column so nothing was hidden — but in use, pinning 2 panels then displayed 3 columns, which read as "all three pinned" and was confusing (and inconsistent with what persisted). **Revised behavior:** the split view shows **exactly the pinned panels** as columns (pin 2 → 2 columns, pin 3 → 3). Unpinned panels are not shown as columns. Because the right-click-tab "Show side-by-side" gesture only works in tabbed mode, an always-available **View ▸ Side-by-side panels ▸ Export / Crop / Scan** submenu of checkable toggles is the way to pin/unpin any panel (including adding a 3rd while already in split view). The `if leftovers:` block below is removed; the View submenu + its sync in `_refresh_deck_layout` replace it.
|
||||
|
||||
**Mirror these existing playlist members** (study them — the deck is a simpler, fixed-3-panel version): `_PlaylistTabBar` (main.py:3284), `_refresh_layout` (~4872), `_on_pin_toggle`/`_on_unpin` (~4942), `_detach_all_pws`/`_clear_split_container` (~4861), and the `_list_stack`/`_split_container` setup (~3916–3923).
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Panel identity
|
||||
The deck's three pages (`_tab_export`, `_tab_crop`, `_tab_scan`) each get three attributes (set in `_build_control_deck`):
|
||||
- `_pinned: bool = False`
|
||||
- `_label: str` — "Export" / "Crop & Track" / "Scan"
|
||||
- `_deck_key: str` — "export" / "crop" / "scan" (stable key for persistence)
|
||||
|
||||
Keep an ordered list `self._deck_panels = [self._tab_export, self._tab_crop, self._tab_scan]` for deterministic column order.
|
||||
|
||||
### Tab bar
|
||||
New `class _DeckTabBar(QTabBar)` (minimal version of `_PlaylistTabBar`): on `contextMenuEvent`, show a checkable "Show side-by-side" action reflecting the page's `_pinned`, and emit `pin_toggle_requested(idx)` when chosen. No rename/folder. Install via `self._control_deck.setTabBar(_DeckTabBar())` in `_build_control_deck` and connect `pin_toggle_requested → self._on_deck_pin_toggle`.
|
||||
|
||||
### Stacked container (mirrors `_list_stack`)
|
||||
Wrap the deck so it can swap between tabbed and split views:
|
||||
- `self._deck_split_container = QWidget()` with an `QHBoxLayout` (`_deck_split_layout`, margins 0, spacing 2).
|
||||
- `self._deck_stack = QStackedWidget()`; page 0 = `self._control_deck`, page 1 = `self._deck_split_container`.
|
||||
- In `right_layout`, mount `self._deck_stack` where `self._control_deck` is currently added (replace that one `addWidget`).
|
||||
|
||||
### `_refresh_deck_layout()` (mirrors `_refresh_layout`)
|
||||
```
|
||||
pinned = [p for p in self._deck_panels if p._pinned]
|
||||
guard self._deck_loading = True (avoid re-entrant signals)
|
||||
detach all panels (setParent(None)); self._control_deck.clear(); clear _deck_split_layout
|
||||
if len(pinned) >= 2:
|
||||
splitter = QSplitter(Horizontal); splitter.setChildrenCollapsible(False)
|
||||
leftovers = []
|
||||
for panel in self._deck_panels: # preserve deck order
|
||||
if panel._pinned:
|
||||
col = QWidget(); v = QVBoxLayout(col) (0 margins)
|
||||
header = label(panel._label, bold) + "✕" button (unpin, fixed 18x18,
|
||||
tooltip "Return to tabs", clicked → self._on_deck_unpin(panel))
|
||||
header fixed height ~22
|
||||
panel.setVisible(True) # reparented pages start hidden
|
||||
v.addWidget(header); v.addWidget(panel, 1)
|
||||
splitter.addWidget(col)
|
||||
else:
|
||||
leftovers.append(panel)
|
||||
if leftovers: # keep unpinned reachable as a tab-column
|
||||
lt = QTabWidget(); lt.setDocumentMode(True)
|
||||
for panel in leftovers:
|
||||
panel.setVisible(True); lt.addTab(panel, panel._label)
|
||||
splitter.addWidget(lt)
|
||||
splitter.setSizes([1000]*splitter.count())
|
||||
_deck_split_layout.addWidget(splitter)
|
||||
self._deck_stack.setCurrentWidget(self._deck_split_container)
|
||||
else:
|
||||
for panel in self._deck_panels: # fixed order
|
||||
self._control_deck.addTab(panel, panel._label)
|
||||
self._deck_stack.setCurrentWidget(self._control_deck)
|
||||
restore self._deck_loading
|
||||
```
|
||||
|
||||
### Toggle handlers (mirror `_on_pin_toggle`/`_on_unpin`)
|
||||
- `_on_deck_pin_toggle(idx)`: `panel = self._control_deck.widget(idx)` (only valid in tabbed mode — pin is only offered there); flip `panel._pinned`; if now pinned and `<2` pinned, `_show_status("Pin another panel to show them side-by-side", 3500)`; `_refresh_deck_layout()`; `_save_deck_layout()`.
|
||||
- `_on_deck_unpin(panel)`: `panel._pinned = False`; `_refresh_deck_layout()`; `_save_deck_layout()`.
|
||||
|
||||
### Persistence
|
||||
- `_save_deck_layout()`: `self._settings.setValue("deck_pinned", [p._deck_key for p in self._deck_panels if p._pinned])`.
|
||||
- Restore at the end of `__init__` (after the deck + menubar exist): read `deck_pinned` (handle str/list like the subprofiles loader at main.py:3867), set each panel's `_pinned`, then `_refresh_deck_layout()` once.
|
||||
|
||||
### Height
|
||||
The deck pages now also render with a 22px header in split mode. After building, set the stack's minimum height to fit the tallest **split-mode** column (header + Export content) so split mode never clips: compute once via `self._deck_stack.setMinimumHeight(...)` using `sizeHint`, and keep vertical size policy `Fixed` (as the deck has now). Switching INTO split mode may change the deck height slightly (deliberate user action — acceptable); switching tabs within tabbed mode must still not jump. Reuse the existing height-pin logic — apply it to `_deck_stack` instead of `_control_deck`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation tasks (bite-sized, commit per task)
|
||||
|
||||
**Task M.1 — scaffolding (no behavior change yet).** Add `_DeckTabBar`; in `_build_control_deck` set it on the deck, set `_pinned/_label/_deck_key` on the three pages, build `self._deck_panels`, create `_deck_split_container`/`_deck_split_layout`/`_deck_stack`, and mount `_deck_stack` in `right_layout` instead of `_control_deck`. Connect `pin_toggle_requested` to a stub. App still behaves as plain tabs. Verify: `import main`, structure tests 6/6, and a probe that `_deck_stack.currentWidget() is _control_deck`.
|
||||
|
||||
**Task M.2 — split rendering.** Implement `_refresh_deck_layout`, `_detach_deck_panels`, `_clear_deck_split`, `_on_deck_pin_toggle`, `_on_deck_unpin`. Verify with a probe: set two panels `_pinned=True`, call `_refresh_deck_layout()`, assert stack shows `_deck_split_container`, the splitter has 3 columns (2 pinned + 1 leftover QTabWidget), and all three panels are visible/parented; unpin one → back to `_control_deck` with 3 tabs in order.
|
||||
|
||||
**Task M.3 — persistence.** Add `_save_deck_layout()` + restore block in `__init__`. Verify a probe round-trips a pinned set through QSettings (use an isolated QSettings scope in the test if needed) without error and that restore calls refresh exactly once.
|
||||
|
||||
**Task M.4 — height + tests.** Apply the height-pin to `_deck_stack`; confirm split mode doesn't clip the tallest column. Add structure tests: `test_deck_stack_exists`, and `test_pinning_two_panels_switches_to_split` (programmatically pin 2, refresh, assert `_deck_stack.currentWidget() is _deck_split_container`).
|
||||
|
||||
## Verification note
|
||||
Env quirk (same as the restructure): bare `python -c` constructing `MainWindow` segfaults on mpv GL; run checks under the pytest fixture and `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen`. Visual confirmation (drag dividers, pin/unpin gestures, persistence across real launches) is the user's, done at the end.
|
||||
|
||||
## Risks
|
||||
- **Reparenting hidden pages:** QTabWidget hides non-current pages; reparented panels must be `setVisible(True)` in split columns (same gotcha the playlist documents at main.py:4909-4911).
|
||||
- **Signal re-entrancy:** guard with `_deck_loading` during refresh.
|
||||
- **Pin offered in split mode:** `_on_deck_pin_toggle` reads `_control_deck.widget(idx)`, which is only meaningful in tabbed mode. The ✕ header is the unpin path in split mode — don't rely on the context menu there.
|
||||
- **Height jump on mode toggle:** acceptable (deliberate); tab-switch-within-tabs must remain jump-free.
|
||||
@@ -0,0 +1,66 @@
|
||||
# LTX-2 per-tab export mode — Design
|
||||
|
||||
**Goal:** Add an export *pipeline mode* to each file-list tab — **Foley** (current behavior) or **LTX-2** — so the same source videos can feed both a Foley dataset (8 s clips) and an LTX-2 V2A dataset (frame-exact, ÷32, 25 fps) without the two ever mixing.
|
||||
|
||||
**Depends on:** the per-tab export folder feature (branch `tab-export-folder`) — this design extends that per-tab state. Implementation branch `ltx2-preset` is based on it.
|
||||
|
||||
**Scope:** soft preset (no hard enforcement — defaults are LTX-2-legal but every control stays editable). `core/` gains optional pipeline params; Foley path is byte-for-byte unchanged.
|
||||
|
||||
---
|
||||
|
||||
## LTX-2 constraints (why this exists)
|
||||
|
||||
LTX-2 (32× spatial VAE, 8× temporal + 1) requires, for a clip:
|
||||
- **W and H each divisible by 32.**
|
||||
- **Frame count F such that `F % 8 == 1`** → 9, 17, 25, … 201, … (transformer seq-len ∝ `(W/32)·(H/32)·((F−1)/8+1)`).
|
||||
- **fps** only sets real duration `F/fps`; for V2A it fixes the paired-audio length and audio↔motion sync, so it must be **consistent across the dataset and equal to the inference `frame_rate`**. Target: **25 fps**.
|
||||
- V2A video is frozen conditioning → low spatial res (384–512) is fine and cheaper.
|
||||
|
||||
Note: 8 s @ 25 fps = 200 frames, and `200 % 8 == 0` → **8 s is not legal**. Nearest legal: F=193 (7.72 s) or **F=201 (8.04 s)**.
|
||||
|
||||
---
|
||||
|
||||
## Model: per-tab mode
|
||||
|
||||
Each tab (`PlaylistWidget`) gains `_mode ∈ {"foley","ltx2"}`, persisted alongside `_dest_folder`/`_pinned`/`_tab_folder` in `_save_playlist_tabs`/`_load_playlist_tabs`. Default `"foley"` → existing tabs load unchanged. The **active tab's mode drives the export pipeline and the length control.**
|
||||
|
||||
### Tab context menu (`_DeckTabBar`/`_PlaylistTabBar`)
|
||||
- **Duplicate as LTX-2** — headline action: clone the tab's file list + separators into a new tab; set `mode="ltx2"`; derive a separate export folder `"<dest_folder>_ltx2"`; load LTX-2 default geometry. Lets you spin an LTX-2 dataset off a Foley working set.
|
||||
- **Duplicate tab** — clone keeping the same mode.
|
||||
- **LTX-2 mode** — checkable, flips an existing tab between foley/ltx2.
|
||||
- Tab label shows a small **`[LTX2]`** badge when `mode=="ltx2"`.
|
||||
|
||||
## What `ltx2` mode changes (soft — still editable)
|
||||
|
||||
| Aspect | Foley | LTX-2 |
|
||||
|--------|-------|-------|
|
||||
| Clip length | Duration spinbox (seconds) | **Frame-count F** control stepping the legal series (9, 17, …, 201, …); shows `= F/25 s` |
|
||||
| Output fps | inherits source | **forced 25 fps** (resample; preserves duration/sync) |
|
||||
| Output W×H | short-side resize → even long side | **center-cropped to ÷32** on both axes (no aspect distortion; loses ≤31 px/side); resize default **512** |
|
||||
| Frame exactness | duration-based | exactly **F** frames (`-frames:v F`) |
|
||||
|
||||
Defaults loaded on convert: resize **512**, **F = 201** (≈8.04 s, mirrors the 8 s Foley clips), ratio as set. All editable afterward.
|
||||
|
||||
## Pipeline (`core/ffmpeg.build_ffmpeg_command`)
|
||||
|
||||
Add optional params; Foley calls pass none → identical output to today:
|
||||
- `target_fps: float | None` — when set, append `fps={target_fps}` filter and `-r {target_fps}`.
|
||||
- `snap32: bool` — when true, after the scale append a centered crop to the nearest lower multiple of 32 on each axis: `crop=trunc(iw/32)*32:trunc(ih/32)*32`.
|
||||
- Frame-exact length: caller computes `duration = F/target_fps` and passes `-frames:v F` on the video output so the clip has exactly F frames; audio extract uses the same `F/target_fps` duration so V2A pairing stays aligned.
|
||||
|
||||
Filter order: portrait-crop (aspect) → scale (short side, ÷32 default) → snap32 crop → fps. The snap32 center-crop runs after scaling so the ÷32 trim is on final pixels.
|
||||
|
||||
## UI wiring (`MainWindow`)
|
||||
|
||||
- The length spinbox area swaps with the active tab's mode: Foley shows *Duration (s)*; LTX-2 shows *Frames (F)* with a live `= s @25fps` readout. Switching tabs (or toggling mode) reconfigures it; uses the existing `_sync_folder_field_to_tab`-style sync hook on tab change.
|
||||
- `_on_export` / `_start_export_batch`: when the active tab is `ltx2`, pass `target_fps=25`, `snap32=True`, and frame-exact length to the ffmpeg builder; otherwise unchanged.
|
||||
- The mismatch guardrail (just added) and per-tab folder continue to apply.
|
||||
|
||||
## Persistence & migration
|
||||
`_mode` added to each tab's saved JSON (default `"foley"` when absent). No DB changes. Existing sessions load every tab as Foley → zero behavior change until a tab is converted.
|
||||
|
||||
## What this does NOT do
|
||||
- No hard enforcement: you can set an illegal F or non-÷32 resize manually; the pipeline still crops to ÷32 and uses whatever F you pick (the *control* defaults/steps keep you legal, but nothing blocks you).
|
||||
- No motion interpolation on fps resample (frame drop/dup only); keep sources native 25 fps where possible.
|
||||
- No change to Foley exports, the scan pipeline, or the DB schema.
|
||||
- No automatic re-export of existing clips into LTX-2 — you cut LTX-2 clips in the converted tab.
|
||||
@@ -0,0 +1,179 @@
|
||||
# LTX-2 per-tab export mode — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a per-tab export pipeline mode (Foley | LTX-2) so the same videos can feed both an 8 s Foley dataset and a frame-exact, ÷32, 25 fps LTX-2 V2A dataset, with a "Duplicate as LTX-2" tab action.
|
||||
|
||||
**Architecture:** `core/ffmpeg.build_ffmpeg_command` gains optional `target_fps` / `snap32` / `frames` params (Foley path unchanged); a tiny `core/ltx2.py` holds the legal-frame math. `PlaylistWidget` gains `_mode`; the tab menu gains duplicate/convert actions; the length control + `_on_export` wiring switch on the active tab's mode. Soft preset — defaults are legal, everything stays editable.
|
||||
|
||||
**Tech Stack:** Python 3.11+, PyQt6, ffmpeg, pytest. Branch `ltx2-preset` (based on `tab-export-folder`). Design: `docs/plans/2026-06-18-ltx2-preset-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
- **Core (`core/ffmpeg.py`, `core/ltx2.py`) is real TDD** — pure functions tested in `tests/test_utils.py` style. Run: `LD_PRELOAD=/usr/lib/libstdc++.so.6 python -m pytest tests/test_utils.py -q` (the preload is needed because importing `main` pulls `mpv`; see `project_qt_test_env`). 3 pre-existing failures there are unrelated — don't count them.
|
||||
- **GUI parts** verified by the offscreen structure test (`LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v`) plus a **manual launch** (`./8cut.sh`).
|
||||
- Line numbers are starting anchors; locate by symbol. Commit per task. Co-author trailer on every commit:
|
||||
`Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
|
||||
|
||||
---
|
||||
|
||||
## Stage 1 — LTX-2 math (`core/ltx2.py`) [TDD]
|
||||
|
||||
### Task 1.1: legal-frame helpers
|
||||
**Files:** Create `core/ltx2.py`; Test in `tests/test_utils.py` (append).
|
||||
|
||||
**Step 1 — failing tests** (append to `tests/test_utils.py`):
|
||||
```python
|
||||
from core.ltx2 import is_legal_frames, nearest_legal_frames, frames_for_duration, duration_for_frames, legal_frames
|
||||
|
||||
def test_ltx2_is_legal():
|
||||
assert is_legal_frames(201) and is_legal_frames(9) and is_legal_frames(25)
|
||||
assert not is_legal_frames(200) and not is_legal_frames(8)
|
||||
|
||||
def test_ltx2_nearest():
|
||||
assert nearest_legal_frames(200) == 201 # 200 -> nearest 8k+1
|
||||
assert nearest_legal_frames(196) == 193
|
||||
assert nearest_legal_frames(5) == 9 # floor at 9
|
||||
|
||||
def test_ltx2_duration_roundtrip():
|
||||
assert duration_for_frames(201, 25) == 201 / 25
|
||||
assert frames_for_duration(8.0, 25) == 201 # 200 -> 201
|
||||
|
||||
def test_ltx2_legal_series():
|
||||
s = legal_frames(min_f=9, max_f=33)
|
||||
assert s == [9, 17, 25, 33]
|
||||
```
|
||||
**Step 2 — run, expect ImportError/FAIL:** `LD_PRELOAD=/usr/lib/libstdc++.so.6 python -m pytest tests/test_utils.py -k ltx2 -q`
|
||||
|
||||
**Step 3 — implement `core/ltx2.py`:**
|
||||
```python
|
||||
"""LTX-2 frame-count math. Legal F satisfy F % 8 == 1 (8x temporal + 1)."""
|
||||
|
||||
def is_legal_frames(f: int) -> bool:
|
||||
return f >= 9 and f % 8 == 1
|
||||
|
||||
def legal_frames(min_f: int = 9, max_f: int = 1000) -> list[int]:
|
||||
start = max(9, min_f + ((1 - min_f) % 8)) # first 8k+1 >= min_f
|
||||
return list(range(start, max_f + 1, 8))
|
||||
|
||||
def nearest_legal_frames(f: int) -> int:
|
||||
if f <= 9:
|
||||
return 9
|
||||
low = ((f - 1) // 8) * 8 + 1
|
||||
high = low + 8
|
||||
return low if (f - low) <= (high - f) else high
|
||||
|
||||
def duration_for_frames(frames: int, fps: float) -> float:
|
||||
return frames / fps
|
||||
|
||||
def frames_for_duration(duration: float, fps: float) -> int:
|
||||
return nearest_legal_frames(round(duration * fps))
|
||||
```
|
||||
**Step 4 — run, expect PASS** (same command). **Step 5 — commit:** `feat: LTX-2 legal-frame helpers (core/ltx2.py)`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 2 — ffmpeg pipeline params [TDD]
|
||||
|
||||
### Task 2.1: `target_fps`, `snap32`, `frames` in `build_ffmpeg_command`
|
||||
**Files:** Modify `core/ffmpeg.py:74` (`build_ffmpeg_command`); Test `tests/test_utils.py`.
|
||||
|
||||
**Step 1 — failing tests:**
|
||||
```python
|
||||
def test_ffmpeg_ltx2_fps_and_frames():
|
||||
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
|
||||
short_side=512, target_fps=25, frames=201)
|
||||
assert "-r" in cmd and cmd[cmd.index("-r")+1] == "25"
|
||||
assert "-frames:v" in cmd and cmd[cmd.index("-frames:v")+1] == "201"
|
||||
vf = cmd[cmd.index("-vf")+1]
|
||||
assert "fps=25" in vf
|
||||
|
||||
def test_ffmpeg_ltx2_snap32_crop():
|
||||
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
|
||||
short_side=512, snap32=True)
|
||||
vf = cmd[cmd.index("-vf")+1]
|
||||
assert "crop=trunc(iw/32)*32:trunc(ih/32)*32" in vf
|
||||
|
||||
def test_ffmpeg_foley_unchanged():
|
||||
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4", short_side=256)
|
||||
assert "-r" not in cmd and "-frames:v" not in cmd
|
||||
assert "crop=trunc" not in cmd[cmd.index("-vf")+1]
|
||||
```
|
||||
**Step 2 — run, expect FAIL** (unexpected kwargs).
|
||||
|
||||
**Step 3 — implement:** add params `target_fps: float | None = None, snap32: bool = False, frames: int | None = None` to the signature. After the scale filter (and before the VAAPI block), append:
|
||||
```python
|
||||
if snap32:
|
||||
filters.append("crop=trunc(iw/32)*32:trunc(ih/32)*32")
|
||||
if target_fps is not None:
|
||||
filters.append(f"fps={target_fps:g}")
|
||||
```
|
||||
Add output flags: after `-t duration` (or near the encoder args, before `output_path`), when `target_fps` set add `cmd += ["-r", f"{target_fps:g}"]`; when `frames` set add `cmd += ["-frames:v", str(frames)]` (video frame cap — exact F). Ensure ordering keeps `-vf` before outputs. Keep `fps`/`snap32` filters out of the `image_sequence=False` vs `True` branches consistently (they apply to both; webp seq also benefits from fps/÷32).
|
||||
|
||||
**Step 4 — run, expect PASS.** Also run full `tests/test_utils.py` (the 3 pre-existing failures only). **Step 5 — commit:** `feat: LTX-2 ffmpeg params (target_fps, snap32, frames)`.
|
||||
|
||||
### Task 2.2: audio extract honors frame-exact duration
|
||||
**Files:** `core/ffmpeg.py:145` (`build_audio_extract_command`) — confirm it takes a duration; if it derives from a fixed 8 s, add a `duration` param so the `.wav` for an LTX-2 webp sequence is exactly `F/25 s`. Add a test mirroring `test_audio_extract_timing` asserting the `-t` value equals `frames/fps`. Commit: `fix: audio extract duration for LTX-2 frame-exact clips`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 3 — per-tab `_mode`
|
||||
|
||||
### Task 3.1: attribute + persistence + migration
|
||||
**Files:** `main.py` — `PlaylistWidget.__init__` (~3409, next to `_dest_folder`); `_save_playlist_tabs` (~5271); `_load_playlist_tabs` (~5315).
|
||||
- Add `self._mode: str = "foley"` in `PlaylistWidget.__init__`.
|
||||
- `_save_playlist_tabs`: add `"mode": pw._mode` to each tab dict.
|
||||
- `_load_playlist_tabs`: after creating each pw, `pw._mode = t.get("mode", "foley")`.
|
||||
- `_add_playlist_tab`: new tabs default `_mode="foley"` (already via init).
|
||||
|
||||
**Verify:** structure test passes; add `test_tab_mode_defaults_foley` (construct, assert each `_pws[i]._mode == "foley"`). Commit: `feat: per-tab export mode attribute (foley default)`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — tab menu: duplicate / convert / toggle
|
||||
|
||||
### Task 4.1: menu actions + label badge
|
||||
**Files:** `main.py` — `_PlaylistTabBar.contextMenuEvent` (~3300) add items; new handlers in `MainWindow`; tab-title rendering.
|
||||
- Add to the tab context menu: **"Duplicate tab"**, **"Duplicate as LTX-2"**, and a checkable **"LTX-2 mode"** (checked when `pw._mode=="ltx2"`). Emit new signals (e.g. `duplicate_requested(idx, as_ltx2: bool)`, `mode_toggle_requested(idx)`) like the existing `pin_toggle_requested`.
|
||||
- `MainWindow._on_duplicate_tab(idx, as_ltx2)`: build a new tab via `_add_playlist_tab(label=…, files=list(src._paths), separators=sorted(src._separators_before), select=True)`; set `pw._dest_folder = src._dest_folder + ("_ltx2" if as_ltx2 else "")`; `pw._mode = "ltx2" if as_ltx2 else src._mode`; if ltx2, apply LTX-2 defaults (Stage 5 hook); `_save_playlist_tabs()`; refresh.
|
||||
- `MainWindow._on_tab_mode_toggle(idx)`: flip `pw._mode`; if now ltx2, apply LTX-2 defaults; `_save_playlist_tabs()`; re-sync controls (Stage 5).
|
||||
- Label badge: when adding/refreshing a tab whose `_mode=="ltx2"`, show `f"{label} [LTX2]"` (or set a distinct color) — apply in `_refresh_layout`/`_add_playlist_tab` title set.
|
||||
|
||||
**Verify:** manual launch — right-click a tab → Duplicate as LTX-2 creates a `[LTX2]` tab with `_ltx2` folder; toggle works. Structure test still green. Commit: `feat: tab duplicate / Duplicate-as-LTX-2 / mode toggle + [LTX2] badge`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 5 — length control swap + export wiring
|
||||
|
||||
### Task 5.1: length control reflects active tab mode
|
||||
**Files:** `main.py` — the clip-length widgets (`_spn_clip_dur` ~4051 area) + the tab-change sync hook (`_on_tab_changed` / `_sync_folder_field_to_tab` neighbor).
|
||||
- Add a frames spinbox `_spn_frames` (min 9, singleStep 8 → always 8k+1; suffix " f"; tooltip live `= F/25 s`). Default 201.
|
||||
- Add `_apply_mode_to_controls()`: if active tab `ltx2` → show `_spn_frames` (+ "Frames" label), hide the seconds Duration control, default resize 512 if unset; else show Duration (seconds), hide frames. Call it from `_on_tab_changed`, after `_on_duplicate_tab`/`_on_tab_mode_toggle`, and once after `_load_playlist_tabs`.
|
||||
- A small label shows `= {F/25:.2f}s @25fps` updating on `_spn_frames.valueChanged`.
|
||||
|
||||
### Task 5.2: route LTX-2 params through export
|
||||
**Files:** `main.py` — `_on_export` (~7317) + `ExportWorker` construction (~7484) + `_update_next_label`.
|
||||
- When the active tab's `_mode=="ltx2"`: compute `frames = self._spn_frames.value()`; `fps = 25`; `duration = frames / fps`; pass `target_fps=25, snap32=True, frames=frames, duration=duration` through to `ExportWorker` → `build_ffmpeg_command`. Default `short_side` to 512 if 0/None in ltx2.
|
||||
- Foley path: unchanged (no new params).
|
||||
- `ExportWorker.__init__`/`run`: thread the new params (default None/False) into `build_ffmpeg_command`.
|
||||
|
||||
**Verify (manual, authoritative):** in an LTX-2 tab, export → inspect an output clip: `ffprobe` shows **25 fps, exactly F frames, W&H ÷32**; a Foley tab still exports 8 s/source-fps unchanged. Structure test green; full `pytest tests/test_utils.py` (3 pre-existing fails only). Commit: `feat: route LTX-2 (25fps, ÷32 crop, F frames) through export for ltx2 tabs`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 6 — finalize
|
||||
- **Task 6.1:** Full regression — `pytest tests/test_ui_structure.py` + `tests/test_utils.py` separately; manual: Foley export unchanged, LTX-2 export legal (ffprobe), duplicate/convert, persistence across relaunch, guardrail + per-tab folder still work.
|
||||
- **Task 6.2:** Changelog (`main.py` CHANGELOG, bump APP_VERSION) + README note (per-tab LTX-2 mode). Commit `docs: changelog + README for LTX-2 export mode`.
|
||||
- **Task 6.3:** Hand off branch (depends on `tab-export-folder`; merge that first, then this).
|
||||
|
||||
## Risks
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| `-frames:v` vs `-t` interaction yields F±1 frames | Set both `-t F/fps` and `-frames:v F`; verify exact count with ffprobe in 5.2. |
|
||||
| `fps` filter + HW (VAAPI) filter ordering | Place `fps`/`snap32` among CPU filters before the VAAPI hwupload block; test a HW-encoder build if available. |
|
||||
| Length-control swap leaves stale state across tab switches | `_apply_mode_to_controls()` called on every tab change + mode toggle + load. |
|
||||
| Depends on unmerged `tab-export-folder` | Branch is based on it; land that branch first. |
|
||||
|
||||
## NOT in scope
|
||||
Hard enforcement (illegal F/resize allowed manually), motion-interpolated fps, auto re-export of existing Foley clips, DB schema changes, scan-pipeline changes.
|
||||
@@ -0,0 +1,273 @@
|
||||
import pytest
|
||||
|
||||
# Redirect QSettings to a throwaway dir BEFORE any MainWindow is constructed, so
|
||||
# these GUI tests can never read or clobber the user's real ~/.config/8cut.conf
|
||||
# (constructing MainWindow loads — and on window close re-saves — the playlist
|
||||
# tabs; a test mutating tab state would otherwise persist into the real session).
|
||||
import tempfile as _tempfile
|
||||
from PyQt6.QtCore import QSettings as _QSettings
|
||||
_QS_DIR = _tempfile.mkdtemp(prefix="8cut-test-qs-")
|
||||
_QSettings.setPath(_QSettings.Format.NativeFormat, _QSettings.Scope.UserScope, _QS_DIR)
|
||||
_QSettings.setPath(_QSettings.Format.IniFormat, _QSettings.Scope.UserScope, _QS_DIR)
|
||||
|
||||
# A real platform is needed because MpvWidget creates a GL context.
|
||||
# If construction fails for any environment reason, skip — this test is a
|
||||
# best-effort structural net, not a gate on core/ tests.
|
||||
pytestmark = pytest.mark.gui
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
inst = QApplication.instance() or QApplication([])
|
||||
yield inst
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def win(app):
|
||||
try:
|
||||
from main import MainWindow
|
||||
w = MainWindow()
|
||||
except Exception as e: # GL/mpv/display unavailable, etc.
|
||||
pytest.skip(f"MainWindow could not be constructed here: {e}")
|
||||
# Deterministic deck state regardless of any persisted side-by-side layout
|
||||
# (construction restores deck_pinned from QSettings).
|
||||
for _p in w._deck_panels:
|
||||
_p._pinned = False
|
||||
w._refresh_deck_layout()
|
||||
yield w
|
||||
w.close()
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_window_constructs(win):
|
||||
assert win.windowTitle().startswith("8-cut")
|
||||
|
||||
|
||||
def test_status_bar_exists(win):
|
||||
assert win.statusBar() is not None
|
||||
|
||||
|
||||
def test_workers_spinbox_in_export_tab(win):
|
||||
from PyQt6.QtWidgets import QSpinBox
|
||||
assert win._spn_workers in win._tab_export.findChildren(QSpinBox)
|
||||
|
||||
|
||||
def test_scan_button_in_scan_tab(win):
|
||||
from PyQt6.QtWidgets import QPushButton
|
||||
assert win._btn_scan in win._tab_scan.findChildren(QPushButton)
|
||||
|
||||
|
||||
def test_portrait_combo_in_crop_tab(win):
|
||||
from PyQt6.QtWidgets import QComboBox
|
||||
assert win._cmb_portrait in win._tab_crop.findChildren(QComboBox)
|
||||
|
||||
|
||||
def test_menu_only_buttons_not_in_deck(win):
|
||||
from PyQt6.QtWidgets import QPushButton
|
||||
deck_btns = win._control_deck.findChildren(QPushButton)
|
||||
assert win._btn_train not in deck_btns
|
||||
assert win._btn_scan_all not in deck_btns
|
||||
assert win._btn_hide_subcats not in deck_btns
|
||||
|
||||
|
||||
def test_deck_stack_exists(win):
|
||||
# The deck is wrapped in a stack so it can swap tabbed <-> side-by-side.
|
||||
# Default (nothing pinned) shows the tabbed control deck.
|
||||
assert win._deck_stack is not None
|
||||
assert win._deck_stack.currentWidget() is win._control_deck
|
||||
|
||||
|
||||
def _split_columns(win):
|
||||
"""Widgets of the splitter actually mounted in the layout (not findChild,
|
||||
which can return a stale deleteLater'd splitter)."""
|
||||
from PyQt6.QtWidgets import QSplitter
|
||||
item = win._deck_split_layout.itemAt(0)
|
||||
spl = item.widget() if item else None
|
||||
assert isinstance(spl, QSplitter)
|
||||
return [spl.widget(i) for i in range(spl.count())]
|
||||
|
||||
|
||||
def test_pinning_two_panels_shows_exactly_two_columns(win):
|
||||
# Pin two panels directly (avoid the toggle handler so no QSettings write
|
||||
# leaks into other test windows) and refresh.
|
||||
from PyQt6.QtWidgets import QTabWidget
|
||||
win._tab_export._pinned = True
|
||||
win._tab_crop._pinned = True
|
||||
win._refresh_deck_layout()
|
||||
assert win._deck_stack.currentWidget() is win._deck_split_container
|
||||
cols = _split_columns(win)
|
||||
assert len(cols) == 2 # only the pinned ones
|
||||
assert not any(isinstance(c, QTabWidget) for c in cols) # no leftover tab-column
|
||||
|
||||
|
||||
def test_side_by_side_menu_pins_third_panel(win):
|
||||
# In split mode the View ▸ Side-by-side menu is the way to pin a 3rd panel
|
||||
# (there's no tab bar to right-click). Suppress the QSettings save via the
|
||||
# _deck_loading guard so this doesn't leak into other windows.
|
||||
win._tab_export._pinned = True
|
||||
win._tab_scan._pinned = True
|
||||
win._refresh_deck_layout()
|
||||
assert len(_split_columns(win)) == 2
|
||||
act = next(a for a, p in win._deck_pin_actions if p is win._tab_crop)
|
||||
win._deck_loading = True # suppress _save_deck_layout
|
||||
try:
|
||||
act.trigger() # simulate clicking the menu item
|
||||
finally:
|
||||
win._deck_loading = False
|
||||
assert win._tab_crop._pinned is True
|
||||
assert len(_split_columns(win)) == 3
|
||||
|
||||
|
||||
def test_duplicate_tab(win):
|
||||
# Right-click → Duplicate tab: clones files into a new tab with an adapted
|
||||
# name + adapted own folder, no file moves. Suppress QSettings writes via
|
||||
# _loading_tabs so the test can't touch the real session.
|
||||
win._loading_tabs = True
|
||||
try:
|
||||
src = win._pws[0]
|
||||
src._label = "AlexisCrystal"
|
||||
src._dest_folder = "/data/alexis/" # trailing slash, like real folders
|
||||
n_before = len(win._pws)
|
||||
win._on_duplicate_tab(win._playlist_tabs.indexOf(src))
|
||||
finally:
|
||||
win._loading_tabs = False
|
||||
assert len(win._pws) == n_before + 1
|
||||
dup = win._pws[-1]
|
||||
assert dup._label == "AlexisCrystal copy"
|
||||
# sibling, not a child: ".../alexis/" -> ".../alexis_copy" (not ".../alexis/_copy")
|
||||
assert dup._dest_folder == "/data/alexis_copy"
|
||||
|
||||
|
||||
def test_tab_mode_defaults_foley(win):
|
||||
# Fresh tabs use the Foley pipeline; sessions/tabs without a stored mode
|
||||
# load unchanged.
|
||||
assert win._pws
|
||||
for pw in win._pws:
|
||||
assert pw._mode == "foley"
|
||||
|
||||
|
||||
def test_tab_mode_toggle(win):
|
||||
# Right-click → "LTX-2 mode" flips the per-tab mode and the displayed title
|
||||
# gains a [LTX2] badge (without mutating pw._label). Suppress QSettings
|
||||
# writes via _loading_tabs so the test can't touch the real session.
|
||||
win._loading_tabs = True
|
||||
try:
|
||||
win._on_tab_mode_toggle(win._playlist_tabs.indexOf(win._pws[0]))
|
||||
finally:
|
||||
win._loading_tabs = False
|
||||
assert win._pws[0]._mode == "ltx2"
|
||||
assert win._tab_title(win._pws[0]).endswith("[LTX2]")
|
||||
|
||||
|
||||
def test_ltx2_params_none_for_foley(win):
|
||||
# A Foley tab feeds no LTX-2 ffmpeg params into export. Set the mode
|
||||
# explicitly: a prior test's closeEvent can persist an ltx2 tab into the
|
||||
# shared (throwaway) QSettings, so don't rely on the loaded default here.
|
||||
win._playlist._mode = "foley"
|
||||
assert win._ltx2_export_params() is None
|
||||
|
||||
|
||||
def test_ltx2_params_for_ltx2_tab(win):
|
||||
# An ltx2-mode active tab: _ltx2_export_params returns the 25fps / ÷32 /
|
||||
# exact-frames kwargs, and _apply_mode_to_controls swaps the length control
|
||||
# (Duration hidden, frames shown). short_side defaults to 512 when unset.
|
||||
win._spn_resize.setValue(0) # force the 512 LTX-2 default path
|
||||
win._pws[0]._mode = "ltx2"
|
||||
win._active_pw = win._pws[0]
|
||||
win._playlist_tabs.setCurrentWidget(win._pws[0])
|
||||
win._spn_frames.setValue(201)
|
||||
win._apply_mode_to_controls()
|
||||
|
||||
assert win._ltx2_export_params() == {
|
||||
"target_fps": 25.0,
|
||||
"snap32": True,
|
||||
"frames": 201,
|
||||
"duration": 201 / 25,
|
||||
"short_side": 512,
|
||||
}
|
||||
# In offscreen, isVisibleTo(win) may be False for both; assert via the
|
||||
# show/hide flag that the Duration control is hidden in ltx2 mode.
|
||||
assert win._spn_clip_dur.isHidden()
|
||||
assert not win._spn_frames.isHidden()
|
||||
|
||||
|
||||
def test_duplicate_preserves_ltx2_mode(win):
|
||||
# Duplicating an LTX-2 tab must yield an LTX-2 tab (mode is copied alongside
|
||||
# the folder fields). Suppress QSettings writes via _loading_tabs.
|
||||
win._loading_tabs = True
|
||||
try:
|
||||
src = win._pws[0]
|
||||
src._mode = "ltx2"
|
||||
win._on_duplicate_tab(win._playlist_tabs.indexOf(src))
|
||||
finally:
|
||||
win._loading_tabs = False
|
||||
dup = win._pws[-1]
|
||||
assert dup._mode == "ltx2"
|
||||
|
||||
|
||||
def test_frames_snaps_to_legal(win):
|
||||
# A typed (illegal) frame count snaps to the nearest legal 8k+1 value so the
|
||||
# displayed value == the exported value and is always a valid LTX-2 clip.
|
||||
win._spn_frames.setValue(100)
|
||||
win._snap_frames_to_legal() # the editingFinished slot
|
||||
assert win._spn_frames.value() == 97 # nearest 8k+1 to 100
|
||||
assert (win._spn_frames.value() - 1) % 8 == 0
|
||||
|
||||
|
||||
def test_export_base_name_handles_trailing_slash(win):
|
||||
# A folder ending in "/" must still yield the real base name, else
|
||||
# subprofile naming breaks ("_blowjob" instead of "mp4_blowjob").
|
||||
win._txt_folder.setText("/x/AlexisCrystal/mp4/")
|
||||
assert win._export_base_name() == "mp4"
|
||||
win._txt_folder.setText("/x/AlexisCrystal/mp4")
|
||||
assert win._export_base_name() == "mp4"
|
||||
|
||||
|
||||
def test_subprofile_button_visibility_exact_match(win):
|
||||
# A subcategory's export button must track ITS folder exactly. A ghost
|
||||
# "_blowjob" (empty-base leftover) or an unrelated "mp4_no_clap" must NOT
|
||||
# hide the "blowjob"/"clap" buttons (the old fuzzy endswith() match did,
|
||||
# so enabling a subcategory never revealed its export button).
|
||||
win._txt_folder.setText("/x/AlexisCrystal/mp4")
|
||||
win._subprofiles = ["blowjob", "clap"]
|
||||
win._rebuild_subprofile_buttons()
|
||||
btns = {b.text().removeprefix("▸ "): b for b in win._subprofile_btns}
|
||||
|
||||
win._hidden_subcats = {"_blowjob", "mp4_no_clap"}
|
||||
win._apply_subcat_visibility()
|
||||
assert not btns["blowjob"].isHidden() # ghost "_blowjob" must not hide it
|
||||
assert not btns["clap"].isHidden() # "mp4_no_clap" must not hide "clap"
|
||||
|
||||
win._hidden_subcats = {"mp4_blowjob"} # exact folder -> hidden
|
||||
win._apply_subcat_visibility()
|
||||
assert btns["blowjob"].isHidden()
|
||||
assert not btns["clap"].isHidden()
|
||||
|
||||
|
||||
def test_extract_audio_controls_exist(win):
|
||||
from PyQt6.QtWidgets import QPushButton, QDoubleSpinBox
|
||||
assert isinstance(win._btn_extract_audio, QPushButton)
|
||||
assert isinstance(win._spn_audio_len, QDoubleSpinBox)
|
||||
# Disabled until a file is loaded.
|
||||
assert not win._btn_extract_audio.isEnabled()
|
||||
# Arrows step by 1s and there's no practical upper cap (long audio areas).
|
||||
assert win._spn_audio_len.singleStep() == 1.0
|
||||
assert win._spn_audio_len.maximum() >= 3600.0
|
||||
|
||||
|
||||
def test_audio_region_tracks_cursor_and_length(win):
|
||||
# The teal audio band spans [cursor, cursor + length]; changing the length
|
||||
# or moving the cursor moves the band. Fake a loaded file so the guard in
|
||||
# _update_audio_region passes.
|
||||
win._file_path = "/x/video.mp4"
|
||||
win._cursor = 10.0
|
||||
win._spn_audio_len.setValue(4.0) # fires _on_audio_len_changed
|
||||
assert win._timeline._audio_region == (10.0, 14.0)
|
||||
win._cursor = 20.0
|
||||
win._update_audio_region()
|
||||
assert win._timeline._audio_region == (20.0, 24.0)
|
||||
# No file -> band cleared.
|
||||
win._file_path = ""
|
||||
win._update_audio_region()
|
||||
assert win._timeline._audio_region is None
|
||||
@@ -1,5 +1,6 @@
|
||||
import tempfile, os, json
|
||||
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, resolve_keyframe, apply_keyframes_to_jobs
|
||||
from core.ffmpeg import build_audio_clip_command
|
||||
from core.annotations import build_annotation_json_path, upsert_clip_annotation
|
||||
from main import ProcessedDB
|
||||
|
||||
@@ -54,6 +55,27 @@ def test_ffmpeg_command_with_resize():
|
||||
assert cmd[-1] == "/out/clip_001.mp4"
|
||||
|
||||
|
||||
def test_audio_clip_command_exact_length():
|
||||
cmd = build_audio_clip_command("/in/video.mp4", 12.5, 3.2, "/out/clip.wav")
|
||||
assert cmd[0] == "ffmpeg"
|
||||
# fast seek before input, exact duration, no video
|
||||
assert cmd[cmd.index("-ss") + 1] == "12.5"
|
||||
assert cmd[cmd.index("-t") + 1] == "3.2"
|
||||
assert cmd.index("-ss") < cmd.index("-i")
|
||||
assert "-vn" in cmd
|
||||
assert cmd[-1] == "/out/clip.wav"
|
||||
|
||||
def test_audio_clip_command_codec_by_extension():
|
||||
assert "pcm_s16le" in build_audio_clip_command("/in.mp4", 0, 1, "/o/a.wav")
|
||||
assert "libmp3lame" in build_audio_clip_command("/in.mp4", 0, 1, "/o/a.mp3")
|
||||
assert "flac" in build_audio_clip_command("/in.mp4", 0, 1, "/o/a.flac")
|
||||
# Unknown extension -> no explicit -c:a, let ffmpeg pick from the container.
|
||||
assert "-c:a" not in build_audio_clip_command("/in.mp4", 0, 1, "/o/a.xyz")
|
||||
|
||||
def test_audio_clip_command_extension_case_insensitive():
|
||||
assert "flac" in build_audio_clip_command("/in.mp4", 0, 1, "/o/A.FLAC")
|
||||
|
||||
|
||||
# --- ProcessedDB ---
|
||||
|
||||
def test_db_add_and_get_markers():
|
||||
@@ -439,3 +461,57 @@ def test_apply_keyframes_before_first_uses_base():
|
||||
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio="4:5",
|
||||
base_rand_p=True, base_rand_s=False)
|
||||
assert result == [(1.0, "/out/a", "4:5", 0.5, True, False)]
|
||||
|
||||
|
||||
# --- LTX-2 legal-frame math (core/ltx2.py) ---
|
||||
|
||||
from core.ltx2 import is_legal_frames, nearest_legal_frames, frames_for_duration, duration_for_frames, legal_frames
|
||||
|
||||
def test_ltx2_is_legal():
|
||||
assert is_legal_frames(201) and is_legal_frames(9) and is_legal_frames(25)
|
||||
assert not is_legal_frames(200) and not is_legal_frames(8)
|
||||
|
||||
def test_ltx2_nearest():
|
||||
assert nearest_legal_frames(200) == 201 # 200 -> nearest 8k+1
|
||||
assert nearest_legal_frames(196) == 193
|
||||
assert nearest_legal_frames(5) == 9 # floor at 9
|
||||
|
||||
def test_ltx2_duration_roundtrip():
|
||||
assert duration_for_frames(201, 25) == 201 / 25
|
||||
assert frames_for_duration(8.0, 25) == 201 # 200 -> 201
|
||||
|
||||
def test_ltx2_legal_series():
|
||||
s = legal_frames(min_f=9, max_f=33)
|
||||
assert s == [9, 17, 25, 33]
|
||||
|
||||
|
||||
# --- LTX-2 ffmpeg params (target_fps, snap32, frames) ---
|
||||
|
||||
def test_ffmpeg_ltx2_fps_and_frames():
|
||||
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
|
||||
short_side=512, target_fps=25, frames=201)
|
||||
assert "-r" in cmd and cmd[cmd.index("-r")+1] == "25"
|
||||
assert "-frames:v" in cmd and cmd[cmd.index("-frames:v")+1] == "201"
|
||||
vf = cmd[cmd.index("-vf")+1]
|
||||
assert "fps=25" in vf
|
||||
|
||||
def test_ffmpeg_ltx2_snap32_crop():
|
||||
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
|
||||
short_side=512, snap32=True)
|
||||
vf = cmd[cmd.index("-vf")+1]
|
||||
assert "crop=trunc(iw/32)*32:trunc(ih/32)*32" in vf
|
||||
|
||||
def test_ffmpeg_foley_unchanged():
|
||||
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4", short_side=256)
|
||||
assert "-r" not in cmd and "-frames:v" not in cmd
|
||||
assert "crop=trunc" not in cmd[cmd.index("-vf")+1]
|
||||
|
||||
|
||||
# --- LTX-2 audio extract frame-exact duration ---
|
||||
|
||||
def test_audio_extract_ltx2_duration():
|
||||
frames, fps = 201, 25
|
||||
cmd = build_audio_extract_command("/in/v.mp4", 0.0, "/out/clip_001",
|
||||
duration=frames / fps)
|
||||
assert "-t" in cmd
|
||||
assert cmd[cmd.index("-t") + 1] == str(frames / fps)
|
||||
|
||||