Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bdeb33a6f | |||
| 387ed7bc6a | |||
| f268d61fe4 | |||
| 24db32c09f | |||
| 0f6ae88ea6 | |||
| 4d99cf6015 | |||
| b75fa85ff5 | |||
| e7d47331c6 | |||
| 7cd31ebe55 | |||
| 3a37dddfd9 | |||
| b249705506 | |||
| aaf405dd3d | |||
| cb2060beb8 | |||
| 0db412baf4 |
+1
-1
@@ -737,7 +737,7 @@ def prefetch_audio(video_path: str, embed_model: str | None = None,
|
||||
def scan_video(
|
||||
video_path: str,
|
||||
model: dict = None,
|
||||
threshold: float = 0.30,
|
||||
threshold: float = 0.50,
|
||||
hop: float = 1.0,
|
||||
window: float = _WINDOW,
|
||||
cancel_flag: object = None,
|
||||
|
||||
+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]:
|
||||
|
||||
+10
-1
@@ -128,7 +128,16 @@ def build_ffmpeg_command(
|
||||
os.path.join(output_path, "frame_%04d.webp"),
|
||||
]
|
||||
else:
|
||||
cmd += ["-c:v", encoder, "-c:a", "pcm_s16le", output_path]
|
||||
cmd += ["-c:v", encoder]
|
||||
if "nvenc" in encoder:
|
||||
cmd += ["-preset", "p4", "-cq", "28"]
|
||||
elif "vaapi" in encoder:
|
||||
cmd += ["-qp", "28"]
|
||||
elif "qsv" in encoder:
|
||||
cmd += ["-global_quality", "28"]
|
||||
elif "amf" in encoder:
|
||||
cmd += ["-qp_i", "28", "-qp_p", "28"]
|
||||
cmd += ["-c:a", "pcm_s16le", output_path]
|
||||
return cmd
|
||||
|
||||
|
||||
|
||||
+8
-2
@@ -24,17 +24,23 @@ def _log(*args) -> None:
|
||||
print(f"[8-cut {ts}]", *args, file=sys.stderr)
|
||||
|
||||
|
||||
def build_export_path(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
||||
def build_export_path(folder: str, basename: str, counter: int,
|
||||
sub: int | None = None, tag: str | None = None) -> str:
|
||||
"""Build clip output path. *folder* should be the vid folder (e.g. .../mp4/vid_001)."""
|
||||
name = f"{basename}_{counter:03d}"
|
||||
if tag is not None:
|
||||
name = f"{name}_{tag}"
|
||||
if sub is not None:
|
||||
name = f"{name}_{sub}"
|
||||
return os.path.join(folder, name + ".mp4")
|
||||
|
||||
|
||||
def build_sequence_dir(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
||||
def build_sequence_dir(folder: str, basename: str, counter: int,
|
||||
sub: int | None = None, tag: str | None = None) -> str:
|
||||
"""Build WebP sequence output dir. *folder* should be the vid folder."""
|
||||
name = f"{basename}_{counter:03d}"
|
||||
if tag is not None:
|
||||
name = f"{name}_{tag}"
|
||||
if sub is not None:
|
||||
name = f"{name}_{sub}"
|
||||
return os.path.join(folder, name)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
# ComfyUI-8cut Node Pack Design
|
||||
|
||||
Date: 2026-04-19
|
||||
|
||||
## Goal
|
||||
|
||||
Port 8-cut's video scanning, training, review, and export workflow to a ComfyUI node pack. The primary motivation is **remote access** — ComfyUI's web UI allows browser-based operation over the network, and HTML5 `<video>` handles streaming compression natively. No tensor-based image pipeline; videos stay as file paths throughout.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Approach
|
||||
|
||||
Monolithic Review Node + simple pipeline nodes. One central **VideoReview** node embeds the full interactive player/timeline/region table as a large DOM widget. Other nodes (Scan, Train, Export) are headless pipeline nodes that pass lightweight metadata.
|
||||
|
||||
### Core reuse
|
||||
|
||||
The entire `8-cut/core/` package is Qt-free and reusable as-is:
|
||||
- `core/audio_scan.py` — `scan_video()`, `train_classifier()`, `load_classifier()`
|
||||
- `core/db.py` — `ProcessedDB` (SQLite, all scan/training/export persistence)
|
||||
- `core/ffmpeg.py` — `build_ffmpeg_command()` (clip export)
|
||||
- `core/tracking.py` — YOLO-based subject tracking
|
||||
- `core/paths.py` — path helpers, `format_time()`
|
||||
|
||||
No porting required — these are imported directly.
|
||||
|
||||
---
|
||||
|
||||
## Node Pack Structure
|
||||
|
||||
```
|
||||
ComfyUI-8cut/
|
||||
__init__.py # NODE_CLASS_MAPPINGS, WEB_DIRECTORY
|
||||
core/ # symlink or copy of 8-cut/core/
|
||||
data/
|
||||
8cut.db # separate SQLite DB (can copy from ~/.8cut.db)
|
||||
models/ # trained classifiers (.joblib)
|
||||
nodes/
|
||||
load_video.py
|
||||
audio_scan.py
|
||||
video_review.py
|
||||
train_model.py
|
||||
export_clips.py
|
||||
server_routes.py # custom API routes
|
||||
web/
|
||||
js/
|
||||
video_review.js # timeline + player + scan panel widget
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Types
|
||||
|
||||
No tensors anywhere in the pipeline. All data flows as lightweight metadata:
|
||||
|
||||
| Type | Python value | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `VIDEO_PATH` | `str` (absolute path) | Video file reference |
|
||||
| `SCAN_REGIONS` | `list[dict]` with start/end/score/model/disabled | Scan output / review edits |
|
||||
| `SCAN_MODEL` | `str` (path to .joblib) | Trained classifier |
|
||||
|
||||
---
|
||||
|
||||
## Nodes
|
||||
|
||||
### LoadVideo
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Input** | `video_path` (STRING, file browser), `profile` (STRING combo from DB profiles) |
|
||||
| **Output** | `VIDEO_PATH`, `filename` (STRING) |
|
||||
| **Logic** | Validates path exists, returns it. Populates profile combo via API route. |
|
||||
|
||||
### AudioScan
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Input** | `VIDEO_PATH`, `SCAN_MODEL`, `threshold` (FLOAT 0-1), `hop` (FLOAT) |
|
||||
| **Output** | `SCAN_REGIONS` |
|
||||
| **Logic** | Calls `core.audio_scan.scan_video()` directly. Progress via `PromptServer.send_sync("progress", ...)`. |
|
||||
|
||||
### VideoReview (interactive, blocking)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Input** | `VIDEO_PATH`, `SCAN_REGIONS` (optional) |
|
||||
| **Output** | `SCAN_REGIONS` (edited) |
|
||||
| **OUTPUT_NODE** | `True` |
|
||||
| **Logic** | Execution pauses here. User interacts via the widget. Clicks "Continue" to pass edited regions downstream. |
|
||||
|
||||
The widget layout:
|
||||
|
||||
```
|
||||
+-------------------------------------+
|
||||
| [video player (HTML5 <video>)] |
|
||||
| +- timeline with scan regions ----+|
|
||||
| | cursor + region drag/resize ||
|
||||
| +---------------------------------+|
|
||||
| +- model tabs [EAT_LARGE][HuBERT]+|
|
||||
| | Time | End | Score ||
|
||||
| | 1:23 | 1:31 | 0.92 ||
|
||||
| | 3:45 | 3:53 | 0.87 ||
|
||||
| | [Add Negative] [Export] [Continue]|
|
||||
| +---------------------------------+|
|
||||
+-------------------------------------+
|
||||
```
|
||||
|
||||
Widget size: ~640x500px minimum, resizable via LiteGraph.
|
||||
|
||||
**Blocking mechanism**: The node's `run()` method blocks on a server-side event/queue. The frontend signals completion via `POST /8cut/review_done/{node_id}`, which unblocks `run()` and returns the edited `SCAN_REGIONS`.
|
||||
|
||||
### TrainModel
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Input** | `profile` (STRING combo), `positive_folder` (STRING combo), `negative_folder` (STRING combo, optional), `embed_model` (STRING combo from `_EMBED_MODELS`), `use_hard_negatives` (BOOL) |
|
||||
| **Output** | `SCAN_MODEL` |
|
||||
| **Logic** | Queries `db.get_training_data()` to assemble `video_infos`, calls `core.audio_scan.train_classifier()`. Saves to `models/{profile}_{embed_model}.joblib` with version rotation. Progress via ComfyUI progress bar. |
|
||||
|
||||
### ExportClips
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Input** | `VIDEO_PATH`, `SCAN_REGIONS`, `output_folder` (STRING), `short_side` (INT), `format` (combo MP4/WEBM), `spread` (FLOAT), `clip_count` (INT), `fuse_gap` (FLOAT) |
|
||||
| **Output** | exported file paths (list) |
|
||||
| **Logic** | Region fusion via `_build_export_spans()`, then `core.ffmpeg.build_ffmpeg_command()` per clip. Records each clip in DB via `db.add()`. |
|
||||
|
||||
### Typical workflow
|
||||
|
||||
```
|
||||
[LoadVideo] --> [AudioScan] --> [VideoReview] --> [ExportClips]
|
||||
^
|
||||
[TrainModel]
|
||||
```
|
||||
|
||||
### Training loop (hard negatives round-trip)
|
||||
|
||||
1. Scan with existing model -> regions in VideoReview
|
||||
2. Review -> mark false positives as negatives (DB)
|
||||
3. Train -> new model uses hard negatives
|
||||
4. Rescan -> better results
|
||||
5. Repeat
|
||||
|
||||
---
|
||||
|
||||
## API Routes
|
||||
|
||||
### Video serving
|
||||
|
||||
| Route | Method | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `/8cut/video` | GET | Serve raw video file via `web.FileResponse`. Query param: `path`. Browser decodes mp4/h264 natively — key for remote streaming. |
|
||||
| `/8cut/video_transcode` | GET | Fallback: transcode to webm on-the-fly via ffmpeg `StreamResponse` for browser-incompatible formats (some MKV, odd codecs). |
|
||||
|
||||
### Region editing (from VideoReview widget)
|
||||
|
||||
| Route | Method | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `/8cut/toggle_region` | POST | `toggle_scan_result_disabled()` |
|
||||
| `/8cut/resize_region` | POST | `update_scan_result()` |
|
||||
| `/8cut/delete_region` | POST | `delete_scan_result()` |
|
||||
| `/8cut/add_negatives` | POST | `add_hard_negatives()` |
|
||||
| `/8cut/scan_versions` | GET | `get_scan_versions()` |
|
||||
| `/8cut/review_done/{node_id}` | POST | Unblock the VideoReview node's `run()`, pass final regions |
|
||||
|
||||
### Data queries (for combo widget population)
|
||||
|
||||
| Route | Method | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `/8cut/profiles` | GET | `db.get_profiles()` |
|
||||
| `/8cut/export_folders` | GET | `db.get_export_folders()` |
|
||||
| `/8cut/models` | GET | List available `.joblib` models |
|
||||
|
||||
---
|
||||
|
||||
## Frontend JS Widget (`web/js/video_review.js`)
|
||||
|
||||
Registered via `app.registerExtension()`. Hooks into the VideoReview node's `onNodeCreated` and `onExecuted` callbacks.
|
||||
|
||||
### Components
|
||||
|
||||
1. **Video player** — HTML5 `<video>` element, src pointed at `/8cut/video?path=...`
|
||||
2. **Timeline** — `<canvas>` overlay below the video. Renders:
|
||||
- Scan region rectangles (color-coded by score, red for negatives, gray for disabled)
|
||||
- Cursor line (click to seek)
|
||||
- Drag handles on region edges (resize)
|
||||
- Waveform (optional, fetched via separate route)
|
||||
3. **Region table** — HTML table with model tabs. Click row to seek. Columns: Time, End, Score.
|
||||
4. **Action buttons** — Add Negative, Export, Continue
|
||||
5. **Version combo** — dropdown to switch scan history versions
|
||||
|
||||
### Interaction flow
|
||||
|
||||
- Widget activates when `onExecuted` fires with scan regions
|
||||
- User clicks/drags timeline, edits regions, marks negatives
|
||||
- Each edit hits an API route (immediate DB persistence)
|
||||
- "Continue" sends `POST /8cut/review_done/{node_id}` with final region state
|
||||
- Node's `run()` unblocks, passes `SCAN_REGIONS` downstream
|
||||
|
||||
---
|
||||
|
||||
## DB
|
||||
|
||||
Separate SQLite DB at `ComfyUI-8cut/data/8cut.db`. Uses the existing `ProcessedDB` class unchanged — same schema, same migration code. Users can copy their existing `~/.8cut.db` to carry over scan history, training data, and hard negatives.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
Same as 8-cut's `requirements.txt` minus PyQt6/python-mpv:
|
||||
- `torch`, `torchaudio`, `torchvision` (from CUDA index)
|
||||
- `transformers>=4.30,<5.0`, `timm>=0.9`
|
||||
- `librosa`, `scikit-learn`, `joblib`, `soundfile`, `numpy`
|
||||
- `ultralytics` (YOLO tracking)
|
||||
|
||||
ComfyUI already provides torch. The node pack's install script just needs the audio/ML extras.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **Node pack skeleton** — structure, `__init__.py`, custom types, API routes for video serving
|
||||
2. **LoadVideo + AudioScan** — headless nodes, no widget needed yet
|
||||
3. **VideoReview widget (minimal)** — video player + static region display + Continue button
|
||||
4. **VideoReview interactivity** — timeline click/drag, region editing, negative marking
|
||||
5. **TrainModel + ExportClips** — complete the pipeline
|
||||
6. **Polish** — version history, waveform overlay, transcode fallback
|
||||
File diff suppressed because it is too large
Load Diff
@@ -195,7 +195,7 @@ class ScanWorker(QThread):
|
||||
progress = pyqtSignal(str) # status message
|
||||
|
||||
def __init__(self, video_path: str, model: dict,
|
||||
threshold: float = 0.30,
|
||||
threshold: float = 0.50,
|
||||
prefetched_audio=None):
|
||||
super().__init__()
|
||||
self._video_path = video_path
|
||||
@@ -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
|
||||
@@ -2694,6 +2733,7 @@ class MainWindow(QMainWindow):
|
||||
self._cursor: float = 0.0
|
||||
self._export_counter: int = 1
|
||||
self._export_worker: ExportWorker | None = None
|
||||
self._export_queue: list[dict] = []
|
||||
self._last_export_path: str = ""
|
||||
self._overwrite_path: str = "" # set when a marker is selected for re-export
|
||||
self._overwrite_group: list[str] = [] # all output_paths in the selected group
|
||||
@@ -2959,7 +2999,7 @@ class MainWindow(QMainWindow):
|
||||
self._sld_threshold.setDecimals(2)
|
||||
self._sld_threshold.setRange(0.0, 1.0)
|
||||
self._sld_threshold.setSingleStep(0.01)
|
||||
self._sld_threshold.setValue(0.30)
|
||||
self._sld_threshold.setValue(0.50)
|
||||
self._sld_threshold.setPrefix("Thr: ")
|
||||
self._sld_threshold.setToolTip("Similarity threshold (0=match everything, 1=exact match)")
|
||||
|
||||
@@ -4181,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)
|
||||
@@ -4491,9 +4533,6 @@ class MainWindow(QMainWindow):
|
||||
if not self._file_path:
|
||||
self._show_status("No video loaded")
|
||||
return
|
||||
if self._export_worker and self._export_worker.isRunning():
|
||||
self._show_status("Export already running…")
|
||||
return
|
||||
if self._scan_worker and self._scan_worker.isRunning():
|
||||
self._show_status("Scan already running")
|
||||
return
|
||||
@@ -4609,47 +4648,82 @@ class MainWindow(QMainWindow):
|
||||
vid_folder = os.path.join(folder, vid_name)
|
||||
os.makedirs(vid_folder, exist_ok=True)
|
||||
|
||||
# Find next counter within the vid folder
|
||||
db_max = self._db.get_max_counter(vid_folder, name) if self._db else 0
|
||||
counter = max(1, db_max + 1)
|
||||
while os.path.exists(build_export_path(vid_folder, name, counter, sub=0)):
|
||||
counter += 1
|
||||
# Extract vid number to use as clip number (vid_003 → 3)
|
||||
vid_num = int(vid_name.split("_")[-1])
|
||||
|
||||
# Clips go flat inside vid folder, numbered sequentially
|
||||
# Clips go flat inside vid folder, numbered by video
|
||||
jobs = []
|
||||
self._auto_export_positions = []
|
||||
positions = []
|
||||
for area_idx, group in enumerate(groups):
|
||||
group_name = f"{name}_{counter:03d}"
|
||||
group_name = f"{name}_{vid_num:03d}_a{area_idx + 1}"
|
||||
for sub, start_t in enumerate(group):
|
||||
fname = f"{group_name}_a{area_idx + 1}_{sub}{ext}"
|
||||
fname = f"{group_name}_{sub}{ext}"
|
||||
out = os.path.join(vid_folder, fname)
|
||||
jobs.append((start_t, out, None, 0.5))
|
||||
self._auto_export_positions.append((start_t, out))
|
||||
counter += 1
|
||||
|
||||
self._show_status(f"Auto: exporting {len(jobs)} clips...")
|
||||
positions.append((start_t, out))
|
||||
|
||||
short_side = self._spn_resize.value() or None
|
||||
self._export_short_side = short_side
|
||||
self._export_portrait = "Off"
|
||||
self._export_crop_center = 0.5
|
||||
self._export_format = fmt
|
||||
self._export_clip_count = 1
|
||||
self._export_spread = spread
|
||||
self._export_folder = folder
|
||||
self._export_folder_suffix = ""
|
||||
self._export_profile = self._profile
|
||||
|
||||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||||
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||||
|
||||
batch = {
|
||||
"jobs": jobs,
|
||||
"positions": positions,
|
||||
"file_path": self._file_path,
|
||||
"short_side": short_side,
|
||||
"image_sequence": image_sequence,
|
||||
"max_workers": max_workers,
|
||||
"encoder": encoder,
|
||||
"spread": spread,
|
||||
"folder": folder,
|
||||
"format": fmt,
|
||||
"profile": self._profile,
|
||||
"is_scan": is_scan,
|
||||
}
|
||||
|
||||
if self._export_worker and self._export_worker.isRunning():
|
||||
self._export_queue.append(batch)
|
||||
n = len(self._export_queue)
|
||||
self._show_status(f"Auto: queued ({n} pending)")
|
||||
self._btn_auto_export.setEnabled(True)
|
||||
return
|
||||
|
||||
self._start_export_batch(batch)
|
||||
|
||||
def _start_export_batch(self, batch: dict) -> None:
|
||||
"""Start an export batch immediately."""
|
||||
self._auto_export_positions = batch["positions"]
|
||||
self._export_short_side = batch["short_side"]
|
||||
self._export_portrait = "Off"
|
||||
self._export_crop_center = 0.5
|
||||
self._export_format = batch["format"]
|
||||
self._export_clip_count = 1
|
||||
self._export_spread = batch["spread"]
|
||||
self._export_folder = batch["folder"]
|
||||
self._export_folder_suffix = ""
|
||||
self._export_profile = batch["profile"]
|
||||
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}")
|
||||
|
||||
self._export_worker = ExportWorker(
|
||||
self._file_path, jobs,
|
||||
short_side=short_side,
|
||||
image_sequence=image_sequence,
|
||||
max_workers=max_workers,
|
||||
encoder=encoder,
|
||||
batch["file_path"], batch["jobs"],
|
||||
short_side=batch["short_side"],
|
||||
image_sequence=batch["image_sequence"],
|
||||
max_workers=batch["max_workers"],
|
||||
encoder=batch["encoder"],
|
||||
)
|
||||
self._export_worker.finished.connect(self._on_auto_clip_done)
|
||||
self._export_worker.all_done.connect(self._on_auto_batch_done)
|
||||
@@ -4668,10 +4742,11 @@ class MainWindow(QMainWindow):
|
||||
start_t = t
|
||||
break
|
||||
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||||
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||||
label = self._txt_label.currentText().strip()
|
||||
category = self._cmb_category.currentText()
|
||||
self._db.add(
|
||||
os.path.basename(self._file_path),
|
||||
os.path.basename(batch_file),
|
||||
start_t,
|
||||
path,
|
||||
label=label,
|
||||
@@ -4683,27 +4758,45 @@ class MainWindow(QMainWindow):
|
||||
clip_count=1,
|
||||
spread=self._export_spread,
|
||||
profile=self._export_profile,
|
||||
source_path=self._file_path,
|
||||
source_path=batch_file,
|
||||
scan_export=is_scan,
|
||||
)
|
||||
if not is_scan:
|
||||
upsert_clip_annotation(self._export_folder, path, label)
|
||||
self._show_status(f"Auto: {os.path.basename(path)}")
|
||||
n_queued = len(self._export_queue)
|
||||
q_msg = f" ({n_queued} queued)" if n_queued else ""
|
||||
self._show_status(f"Auto: {os.path.basename(path)}{q_msg}")
|
||||
_log(f" auto clip done: {os.path.basename(path)}")
|
||||
|
||||
def _on_auto_batch_done(self):
|
||||
n = len(self._auto_export_positions)
|
||||
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||||
batch_profile = self._export_profile
|
||||
|
||||
# Mark the batch's video as done in playlist
|
||||
n_clips = self._db.get_clip_count(os.path.basename(batch_file), batch_profile)
|
||||
self._playlist.mark_done(batch_file, n_clips)
|
||||
|
||||
# If current video matches the batch, refresh its markers
|
||||
if self._file_path == batch_file:
|
||||
self._refresh_markers()
|
||||
self._update_next_label()
|
||||
|
||||
_log(f"Auto export complete: {n} clips ({os.path.basename(batch_file)})")
|
||||
|
||||
# Drain queue
|
||||
if self._export_queue:
|
||||
next_batch = self._export_queue.pop(0)
|
||||
self._show_status(f"Auto: starting next batch ({len(self._export_queue)} remaining)")
|
||||
self._start_export_batch(next_batch)
|
||||
return
|
||||
|
||||
self._btn_auto_export.setEnabled(True)
|
||||
self._btn_cancel.setEnabled(False)
|
||||
self._btn_export.setEnabled(True)
|
||||
self._set_subprofile_btns_enabled(True)
|
||||
self._auto_export_no_markers = False
|
||||
self._refresh_markers()
|
||||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||||
self._playlist.mark_done(self._file_path, n_clips)
|
||||
self._update_next_label()
|
||||
self._show_status(f"Auto export complete: {n} clips")
|
||||
_log(f"Auto export complete: {n} clips")
|
||||
|
||||
def _jump_to_next_scan_region(self) -> None:
|
||||
regions = sorted(self._timeline._scan_regions, key=lambda r: r[0])
|
||||
@@ -4747,18 +4840,17 @@ class MainWindow(QMainWindow):
|
||||
name = self._txt_name.text() or "clip"
|
||||
vid_name = self._get_vid_folder(folder)
|
||||
vid_folder = os.path.join(folder, vid_name)
|
||||
# Start from the highest counter the DB knows about, so we never
|
||||
# reuse a counter if the folder is temporarily empty / unmounted.
|
||||
db_max = self._db.get_max_counter(vid_folder, name) if self._db else 0
|
||||
self._export_counter = max(1, db_max + 1)
|
||||
# Then also skip any files that exist on disk.
|
||||
vid_num = int(vid_name.split("_")[-1])
|
||||
# Find next manual export number (m1, m2, ...)
|
||||
self._export_counter = 1
|
||||
while True:
|
||||
test_path = build_export_path(vid_folder, name, self._export_counter, sub=0)
|
||||
tag = f"m{self._export_counter}"
|
||||
test_path = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||||
if not os.path.exists(test_path):
|
||||
break
|
||||
self._export_counter += 1
|
||||
n = self._spn_clips.value()
|
||||
base = f"{name}_{self._export_counter:03d}"
|
||||
base = f"{name}_{vid_num:03d}_m{self._export_counter}"
|
||||
if n == 1:
|
||||
self._lbl_next.setText(f"→ {vid_name}/{base}_0")
|
||||
else:
|
||||
@@ -4824,27 +4916,29 @@ class MainWindow(QMainWindow):
|
||||
vid_name = self._get_vid_folder(folder)
|
||||
vid_folder = os.path.join(folder, vid_name)
|
||||
os.makedirs(vid_folder, exist_ok=True)
|
||||
# For subprofile exports, calculate counter independently.
|
||||
vid_num = int(vid_name.split("_")[-1])
|
||||
# For subprofile exports, calculate manual counter independently.
|
||||
if folder_suffix:
|
||||
db_max_sub = self._db.get_max_counter(vid_folder, name) if self._db else 0
|
||||
counter = max(1, db_max_sub + 1)
|
||||
manual_n = 1
|
||||
while True:
|
||||
tag = f"m{manual_n}"
|
||||
if image_sequence:
|
||||
p = build_sequence_dir(vid_folder, name, counter, sub=0)
|
||||
p = build_sequence_dir(vid_folder, name, vid_num, sub=0, tag=tag)
|
||||
else:
|
||||
p = build_export_path(vid_folder, name, counter, sub=0)
|
||||
p = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||||
if not os.path.exists(p):
|
||||
break
|
||||
counter += 1
|
||||
manual_n += 1
|
||||
else:
|
||||
counter = self._export_counter
|
||||
manual_n = self._export_counter
|
||||
tag = f"m{manual_n}"
|
||||
jobs = []
|
||||
for sub in range(n_clips):
|
||||
start = self._cursor + sub * spread
|
||||
if image_sequence:
|
||||
out = build_sequence_dir(vid_folder, name, counter, sub=sub)
|
||||
out = build_sequence_dir(vid_folder, name, vid_num, sub=sub, tag=tag)
|
||||
else:
|
||||
out = build_export_path(vid_folder, name, counter, sub=sub)
|
||||
out = build_export_path(vid_folder, name, vid_num, sub=sub, tag=tag)
|
||||
jobs.append((start, out, base_ratio, base_center))
|
||||
|
||||
# Apply crop keyframes (or fall back to base state).
|
||||
@@ -5008,7 +5102,9 @@ class MainWindow(QMainWindow):
|
||||
self._show_status("Cancelling export…")
|
||||
|
||||
def _on_export_cancelled(self):
|
||||
_log("Export cancelled")
|
||||
n_dropped = len(self._export_queue)
|
||||
self._export_queue.clear()
|
||||
_log(f"Export cancelled (dropped {n_dropped} queued)")
|
||||
self._btn_export.setEnabled(True)
|
||||
self._btn_auto_export.setEnabled(True)
|
||||
self._set_subprofile_btns_enabled(True)
|
||||
@@ -5019,7 +5115,10 @@ class MainWindow(QMainWindow):
|
||||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||||
if n_clips:
|
||||
self._playlist.mark_done(self._file_path, n_clips)
|
||||
self._show_status("Export cancelled", 4000)
|
||||
msg = "Export cancelled"
|
||||
if n_dropped:
|
||||
msg += f" ({n_dropped} queued batches dropped)"
|
||||
self._show_status(msg, 4000)
|
||||
|
||||
def changeEvent(self, event):
|
||||
super().changeEvent(event)
|
||||
|
||||
Reference in New Issue
Block a user