Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bdeb33a6f | |||
| 387ed7bc6a | |||
| f268d61fe4 | |||
| 24db32c09f | |||
| 0f6ae88ea6 | |||
| 4d99cf6015 | |||
| b75fa85ff5 |
+32
-11
@@ -418,27 +418,45 @@ class ProcessedDB:
|
||||
pass
|
||||
return max_n
|
||||
|
||||
def delete_scan_exports(self, filename: str, profile: str) -> int:
|
||||
"""Delete all scan_export entries for *filename* in *profile*.
|
||||
|
||||
Returns the number of rows deleted.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return 0
|
||||
cur = self._con.execute(
|
||||
"DELETE FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 1",
|
||||
(filename, profile),
|
||||
)
|
||||
self._con.commit()
|
||||
return cur.rowcount
|
||||
|
||||
def get_vid_folder(self, filename: str, profile: str,
|
||||
export_folder: str) -> str:
|
||||
"""Return the vid_NNN folder name for a source video.
|
||||
|
||||
Checks existing DB output_paths first; if the video already has a
|
||||
vid_NNN folder, returns it. Otherwise assigns the next available
|
||||
number, also checking disk for orphan vid folders.
|
||||
vid_NNN folder, returns it. Otherwise assigns max(existing) + 1,
|
||||
also checking disk for orphan vid folders.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return "vid_001"
|
||||
# Use the most recent entry (ORDER BY rowid DESC) for determinism
|
||||
# when a file has entries across multiple vid folders.
|
||||
row = self._con.execute(
|
||||
"SELECT output_path FROM processed"
|
||||
" WHERE filename = ? AND profile = ? LIMIT 1",
|
||||
" WHERE filename = ? AND profile = ?"
|
||||
" ORDER BY rowid DESC LIMIT 1",
|
||||
(filename, profile),
|
||||
).fetchone()
|
||||
if row:
|
||||
parent = os.path.basename(os.path.dirname(row[0]))
|
||||
if parent.startswith("vid_"):
|
||||
return parent
|
||||
# Collect all existing vid_NNN names from DB + disk
|
||||
existing: set[str] = set()
|
||||
# Collect max vid_NNN number from DB + disk (never reuse old numbers)
|
||||
max_n = 0
|
||||
rows = self._con.execute(
|
||||
"SELECT DISTINCT output_path FROM processed WHERE profile = ?",
|
||||
(profile,),
|
||||
@@ -446,17 +464,20 @@ class ProcessedDB:
|
||||
for (op,) in rows:
|
||||
p = os.path.basename(os.path.dirname(op))
|
||||
if p.startswith("vid_"):
|
||||
existing.add(p)
|
||||
try:
|
||||
max_n = max(max_n, int(p.split("_")[1]))
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
if os.path.isdir(export_folder):
|
||||
for d in os.listdir(export_folder):
|
||||
if d.startswith("vid_") and os.path.isdir(
|
||||
os.path.join(export_folder, d)
|
||||
):
|
||||
existing.add(d)
|
||||
n = 1
|
||||
while f"vid_{n:03d}" in existing:
|
||||
n += 1
|
||||
return f"vid_{n:03d}"
|
||||
try:
|
||||
max_n = max(max_n, int(d.split("_")[1]))
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return f"vid_{max_n + 1:03d}"
|
||||
|
||||
def get_export_folders(self, profile: str = "default",
|
||||
include_scan_exports: bool = False) -> list[str]:
|
||||
|
||||
@@ -881,6 +881,8 @@ class ScanResultsPanel(QWidget):
|
||||
|
||||
table.itemSelectionChanged.connect(
|
||||
lambda t=table: self._on_selection_changed(t))
|
||||
table.cellClicked.connect(
|
||||
lambda r, c, t=table: self._on_cell_clicked(t, r, c))
|
||||
table.cellChanged.connect(
|
||||
lambda r, c, t=table: self._on_cell_changed(t, r, c))
|
||||
container_layout.addWidget(table)
|
||||
@@ -973,9 +975,24 @@ class ScanResultsPanel(QWidget):
|
||||
return ""
|
||||
|
||||
def _on_selection_changed(self, table: QTableWidget) -> None:
|
||||
items = table.selectedItems()
|
||||
if items:
|
||||
row = items[0].row()
|
||||
"""Handle keyboard navigation (arrows) — seek to start of current row."""
|
||||
cur = table.currentItem()
|
||||
if cur is None or not cur.isSelected():
|
||||
selected = table.selectedItems()
|
||||
if not selected:
|
||||
return
|
||||
cur = selected[-1]
|
||||
start = table.item(cur.row(), 0).data(Qt.ItemDataRole.UserRole + 1)
|
||||
if start is not None:
|
||||
self.seek_requested.emit(float(start))
|
||||
|
||||
def _on_cell_clicked(self, table: QTableWidget, row: int, col: int) -> None:
|
||||
"""Click Time → seek to start; click End → seek to last 3s of clip."""
|
||||
if col == 1:
|
||||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||||
if end is not None:
|
||||
self.seek_requested.emit(max(0.0, float(end) - 3.0))
|
||||
else:
|
||||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||||
if start is not None:
|
||||
self.seek_requested.emit(float(start))
|
||||
@@ -1362,8 +1379,14 @@ class ScanResultsPanel(QWidget):
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
_WAVEFORM_CACHE_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"cache", "waveforms",
|
||||
)
|
||||
|
||||
|
||||
class WaveformWorker(QThread):
|
||||
"""Extract a low-res waveform envelope in the background."""
|
||||
"""Extract a low-res waveform envelope in the background (with disk cache)."""
|
||||
done = pyqtSignal(object) # emits numpy array of peak values
|
||||
|
||||
def __init__(self, video_path: str, n_bins: int = 2000):
|
||||
@@ -1371,9 +1394,22 @@ class WaveformWorker(QThread):
|
||||
self._path = video_path
|
||||
self._n_bins = n_bins
|
||||
|
||||
@staticmethod
|
||||
def _cache_path(video_path: str) -> str:
|
||||
import hashlib
|
||||
h = hashlib.md5(video_path.encode()).hexdigest()
|
||||
return os.path.join(_WAVEFORM_CACHE_DIR, f"{h}.npy")
|
||||
|
||||
def run(self):
|
||||
import numpy as np
|
||||
try:
|
||||
# Check cache first
|
||||
cache = self._cache_path(self._path)
|
||||
if os.path.exists(cache):
|
||||
peaks = np.load(cache)
|
||||
self.done.emit(peaks)
|
||||
return
|
||||
|
||||
cmd = [
|
||||
_bin("ffmpeg"), "-i", self._path,
|
||||
"-vn", "-ac", "1", "-ar", "8000",
|
||||
@@ -1393,6 +1429,9 @@ class WaveformWorker(QThread):
|
||||
mx = peaks.max()
|
||||
if mx > 0:
|
||||
peaks = peaks / mx
|
||||
# Save to cache
|
||||
os.makedirs(_WAVEFORM_CACHE_DIR, exist_ok=True)
|
||||
np.save(cache, peaks)
|
||||
self.done.emit(peaks)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -4182,6 +4221,8 @@ class MainWindow(QMainWindow):
|
||||
def _on_scan_seek(self, t: float) -> None:
|
||||
"""Seek player when a scan result row is clicked."""
|
||||
if self._file_path:
|
||||
if not self._btn_scan_mode.isChecked():
|
||||
self._btn_scan_mode.setChecked(True)
|
||||
self._cursor = t
|
||||
self._mpv.seek(t)
|
||||
self._timeline.set_cursor(t)
|
||||
@@ -4666,6 +4707,13 @@ class MainWindow(QMainWindow):
|
||||
self._auto_export_no_markers = batch["is_scan"]
|
||||
self._export_batch_file = batch["file_path"]
|
||||
|
||||
# Replace old scan export entries for this video
|
||||
if batch["is_scan"]:
|
||||
fname = os.path.basename(batch["file_path"])
|
||||
n_old = self._db.delete_scan_exports(fname, batch["profile"])
|
||||
if n_old:
|
||||
_log(f"Replacing {n_old} old scan export entries for {fname}")
|
||||
|
||||
n_queued = len(self._export_queue)
|
||||
q_msg = f" ({n_queued} queued)" if n_queued else ""
|
||||
self._show_status(f"Auto: exporting {len(batch['jobs'])} clips...{q_msg}")
|
||||
|
||||
Reference in New Issue
Block a user