|
|
@@ -35,11 +35,13 @@ import mpv
|
|
|
|
from core.paths import _bin, _log, build_export_path, build_sequence_dir, format_time
|
|
|
|
from core.paths import _bin, _log, build_export_path, build_sequence_dir, format_time
|
|
|
|
from core.ffmpeg import (
|
|
|
|
from core.ffmpeg import (
|
|
|
|
_RATIOS, resolve_keyframe, apply_keyframes_to_jobs,
|
|
|
|
_RATIOS, resolve_keyframe, apply_keyframes_to_jobs,
|
|
|
|
build_ffmpeg_command, build_audio_extract_command, detect_hw_encoders,
|
|
|
|
build_ffmpeg_command, build_audio_extract_command, build_audio_clip_command,
|
|
|
|
|
|
|
|
probe_duration, detect_hw_encoders,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
from core.db import ProcessedDB
|
|
|
|
from core.db import ProcessedDB
|
|
|
|
from core.annotations import remove_clip_annotation, upsert_clip_annotation
|
|
|
|
from core.annotations import remove_clip_annotation, upsert_clip_annotation
|
|
|
|
from core.tracking import track_centers_for_jobs
|
|
|
|
from core.tracking import track_centers_for_jobs
|
|
|
|
|
|
|
|
from core.ltx2 import nearest_legal_frames
|
|
|
|
|
|
|
|
|
|
|
|
_ASSET_DIR = (Path(sys._MEIPASS) if getattr(sys, "frozen", False) else Path(__file__).parent) / "assets"
|
|
|
|
_ASSET_DIR = (Path(sys._MEIPASS) if getattr(sys, "frozen", False) else Path(__file__).parent) / "assets"
|
|
|
|
|
|
|
|
|
|
|
@@ -1895,6 +1897,9 @@ class TimelineWidget(QWidget):
|
|
|
|
self._scan_regions: list[tuple[float, float, float, float, float]] = []
|
|
|
|
self._scan_regions: list[tuple[float, float, float, float, float]] = []
|
|
|
|
self._scan_neg_times: set[float] = set()
|
|
|
|
self._scan_neg_times: set[float] = set()
|
|
|
|
self._active_scan_region: tuple[float, float] | None = None
|
|
|
|
self._active_scan_region: tuple[float, float] | None = None
|
|
|
|
|
|
|
|
# Manual "Extract audio area" band (start, end) — drawn as a distinct
|
|
|
|
|
|
|
|
# teal dashed region so it reads apart from the blue clip selection.
|
|
|
|
|
|
|
|
self._audio_region: tuple[float, float] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
# View window for zoom/pan. When _view_span <= 0 the full duration is shown.
|
|
|
|
# View window for zoom/pan. When _view_span <= 0 the full duration is shown.
|
|
|
|
self._view_start: float = 0.0
|
|
|
|
self._view_start: float = 0.0
|
|
|
@@ -2057,6 +2062,17 @@ class TimelineWidget(QWidget):
|
|
|
|
self._active_scan_region = None
|
|
|
|
self._active_scan_region = None
|
|
|
|
self.update()
|
|
|
|
self.update()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_audio_region(self, start: float, end: float) -> None:
|
|
|
|
|
|
|
|
region = (start, end)
|
|
|
|
|
|
|
|
if region != self._audio_region:
|
|
|
|
|
|
|
|
self._audio_region = region
|
|
|
|
|
|
|
|
self.update()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clear_audio_region(self) -> None:
|
|
|
|
|
|
|
|
if self._audio_region is not None:
|
|
|
|
|
|
|
|
self._audio_region = None
|
|
|
|
|
|
|
|
self.update()
|
|
|
|
|
|
|
|
|
|
|
|
def set_play_position(self, t: float | None) -> None:
|
|
|
|
def set_play_position(self, t: float | None) -> None:
|
|
|
|
# In lock mode, ignore mpv position updates while the user is dragging
|
|
|
|
# In lock mode, ignore mpv position updates while the user is dragging
|
|
|
|
# — the async seek hasn't caught up yet, so mpv reports stale values.
|
|
|
|
# — the async seek hasn't caught up yet, so mpv reports stale values.
|
|
|
@@ -2285,6 +2301,18 @@ class TimelineWidget(QWidget):
|
|
|
|
p.drawLine(x_start, rh, x_start, h)
|
|
|
|
p.drawLine(x_start, rh, x_start, h)
|
|
|
|
p.drawLine(x_end, rh, x_end, h)
|
|
|
|
p.drawLine(x_end, rh, x_end, h)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── audio-extract area (exact length from the playhead) ───────────
|
|
|
|
|
|
|
|
if (not self._scan_mode and self._audio_region is not None
|
|
|
|
|
|
|
|
and self._duration > 0):
|
|
|
|
|
|
|
|
a0, a1 = self._audio_region
|
|
|
|
|
|
|
|
ax1 = int(self._time_to_x(a0))
|
|
|
|
|
|
|
|
ax2 = int(self._time_to_x(min(a1, self._duration)))
|
|
|
|
|
|
|
|
aw = max(ax2 - ax1, 1)
|
|
|
|
|
|
|
|
p.fillRect(ax1, rh, aw, th, QColor(0, 200, 180, 45))
|
|
|
|
|
|
|
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
|
|
|
|
|
|
|
p.setPen(QPen(QColor(0, 220, 190), 1, Qt.PenStyle.DashLine))
|
|
|
|
|
|
|
|
p.drawRect(ax1, rh + 1, aw, th - 2)
|
|
|
|
|
|
|
|
|
|
|
|
# ── ghost of the previous cursor position (undo-by-eye) ──────────
|
|
|
|
# ── ghost of the previous cursor position (undo-by-eye) ──────────
|
|
|
|
if (not self._scan_mode and self._ghost_cursor is not None
|
|
|
|
if (not self._scan_mode and self._ghost_cursor is not None
|
|
|
|
and abs(self._ghost_cursor - self._cursor) > 0.05):
|
|
|
|
and abs(self._ghost_cursor - self._cursor) > 0.05):
|
|
|
@@ -4160,6 +4188,7 @@ class MainWindow(QMainWindow):
|
|
|
|
self._lbl_frames_secs = QLabel()
|
|
|
|
self._lbl_frames_secs = QLabel()
|
|
|
|
self._lbl_frames_secs.setToolTip("Clip length at 25 fps")
|
|
|
|
self._lbl_frames_secs.setToolTip("Clip length at 25 fps")
|
|
|
|
self._spn_frames.valueChanged.connect(self._update_frames_secs_label)
|
|
|
|
self._spn_frames.valueChanged.connect(self._update_frames_secs_label)
|
|
|
|
|
|
|
|
self._spn_frames.editingFinished.connect(self._snap_frames_to_legal)
|
|
|
|
self._update_frames_secs_label()
|
|
|
|
self._update_frames_secs_label()
|
|
|
|
|
|
|
|
|
|
|
|
self._spn_clips = QSpinBox()
|
|
|
|
self._spn_clips = QSpinBox()
|
|
|
@@ -4403,17 +4432,50 @@ class MainWindow(QMainWindow):
|
|
|
|
transport_row.addStretch()
|
|
|
|
transport_row.addStretch()
|
|
|
|
transport_row.addWidget(self._lbl_next)
|
|
|
|
transport_row.addWidget(self._lbl_next)
|
|
|
|
transport_row.addWidget(self._btn_export)
|
|
|
|
transport_row.addWidget(self._btn_export)
|
|
|
|
# Subprofile export buttons sit right after Export
|
|
|
|
transport_row.addWidget(self._btn_cancel)
|
|
|
|
|
|
|
|
transport_row.addWidget(self._btn_delete)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Extract audio area — an exact-length audio slice from the playhead,
|
|
|
|
|
|
|
|
# saved via a Save As dialog (format follows the chosen extension).
|
|
|
|
|
|
|
|
transport_row.addSpacing(12)
|
|
|
|
|
|
|
|
self._spn_audio_len = QDoubleSpinBox()
|
|
|
|
|
|
|
|
# No practical upper cap — audio areas can be minutes long; ffmpeg stops
|
|
|
|
|
|
|
|
# cleanly at end-of-file if the source is shorter. Arrows step by 1s;
|
|
|
|
|
|
|
|
# type for sub-second precision.
|
|
|
|
|
|
|
|
self._spn_audio_len.setRange(0.10, 86400.0)
|
|
|
|
|
|
|
|
self._spn_audio_len.setDecimals(2)
|
|
|
|
|
|
|
|
self._spn_audio_len.setSingleStep(1.0)
|
|
|
|
|
|
|
|
self._spn_audio_len.setSuffix(" s")
|
|
|
|
|
|
|
|
self._spn_audio_len.setFixedWidth(92)
|
|
|
|
|
|
|
|
self._spn_audio_len.setToolTip(
|
|
|
|
|
|
|
|
"Audio area length, measured from the playhead "
|
|
|
|
|
|
|
|
"(arrows step 1s; type for finer)")
|
|
|
|
|
|
|
|
self._spn_audio_len.setValue(
|
|
|
|
|
|
|
|
float(self._settings.value("audio_extract_len", 3.0)))
|
|
|
|
|
|
|
|
self._spn_audio_len.valueChanged.connect(self._on_audio_len_changed)
|
|
|
|
|
|
|
|
self._btn_extract_audio = QPushButton("♪ Extract audio")
|
|
|
|
|
|
|
|
self._btn_extract_audio.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
|
|
|
|
|
|
self._btn_extract_audio.setToolTip(
|
|
|
|
|
|
|
|
"Extract this exact length of audio from the playhead and save it")
|
|
|
|
|
|
|
|
self._btn_extract_audio.setEnabled(False)
|
|
|
|
|
|
|
|
self._btn_extract_audio.clicked.connect(self._on_extract_audio)
|
|
|
|
|
|
|
|
transport_row.addWidget(self._spn_audio_len)
|
|
|
|
|
|
|
|
transport_row.addWidget(self._btn_extract_audio)
|
|
|
|
|
|
|
|
self._transport_row = transport_row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Row 1b — subcategory (subprofile) export buttons live on their own
|
|
|
|
|
|
|
|
# centered row so the (often many) "▸ name" buttons don't crowd the
|
|
|
|
|
|
|
|
# transport controls. Stretches on both ends keep the group centered.
|
|
|
|
|
|
|
|
subprofile_row = QHBoxLayout()
|
|
|
|
|
|
|
|
subprofile_row.addStretch()
|
|
|
|
self._subprofile_btns: list[QPushButton] = []
|
|
|
|
self._subprofile_btns: list[QPushButton] = []
|
|
|
|
self._sub_insert_anchor = self._btn_cancel # buttons inserted before this
|
|
|
|
|
|
|
|
self._btn_add_sub = QPushButton("+")
|
|
|
|
self._btn_add_sub = QPushButton("+")
|
|
|
|
self._btn_add_sub.setFixedWidth(28)
|
|
|
|
self._btn_add_sub.setFixedWidth(28)
|
|
|
|
self._btn_add_sub.setToolTip("Add a subprofile — exports to folder_suffix")
|
|
|
|
self._btn_add_sub.setToolTip("Add a subprofile — exports to folder_suffix")
|
|
|
|
self._btn_add_sub.clicked.connect(self._add_subprofile)
|
|
|
|
self._btn_add_sub.clicked.connect(self._add_subprofile)
|
|
|
|
transport_row.addWidget(self._btn_add_sub)
|
|
|
|
subprofile_row.addWidget(self._btn_add_sub)
|
|
|
|
transport_row.addWidget(self._btn_cancel)
|
|
|
|
subprofile_row.addStretch()
|
|
|
|
transport_row.addWidget(self._btn_delete)
|
|
|
|
self._subprofile_row = subprofile_row
|
|
|
|
self._transport_row = transport_row
|
|
|
|
|
|
|
|
self._rebuild_subprofile_buttons()
|
|
|
|
self._rebuild_subprofile_buttons()
|
|
|
|
|
|
|
|
|
|
|
|
# Row 2/3 — annotation, output path, crop and scan controls all live in
|
|
|
|
# Row 2/3 — annotation, output path, crop and scan controls all live in
|
|
|
@@ -4429,6 +4491,7 @@ class MainWindow(QMainWindow):
|
|
|
|
right_layout.addWidget(self._timeline)
|
|
|
|
right_layout.addWidget(self._timeline)
|
|
|
|
right_layout.addWidget(self._crop_bar)
|
|
|
|
right_layout.addWidget(self._crop_bar)
|
|
|
|
right_layout.addLayout(transport_row)
|
|
|
|
right_layout.addLayout(transport_row)
|
|
|
|
|
|
|
|
right_layout.addLayout(self._subprofile_row)
|
|
|
|
right_layout.addWidget(self._build_control_deck())
|
|
|
|
right_layout.addWidget(self._build_control_deck())
|
|
|
|
self._build_export_tab()
|
|
|
|
self._build_export_tab()
|
|
|
|
self._build_crop_tab()
|
|
|
|
self._build_crop_tab()
|
|
|
@@ -4989,7 +5052,10 @@ class MainWindow(QMainWindow):
|
|
|
|
def _tab_export_folder(self) -> str:
|
|
|
|
def _tab_export_folder(self) -> str:
|
|
|
|
"""The export base folder, with the active tab name appended when its
|
|
|
|
"""The export base folder, with the active tab name appended when its
|
|
|
|
per-tab 'Export to tab-named folder' option is enabled."""
|
|
|
|
per-tab 'Export to tab-named folder' option is enabled."""
|
|
|
|
base = self._txt_folder.text()
|
|
|
|
# rstrip the trailing separator so basename()/suffix logic downstream
|
|
|
|
|
|
|
|
# never sees an empty base (a folder like ".../mp4/" → base "" broke
|
|
|
|
|
|
|
|
# subprofile naming, e.g. "_blowjob" instead of "mp4_blowjob").
|
|
|
|
|
|
|
|
base = self._txt_folder.text().rstrip("/" + os.sep)
|
|
|
|
pw = self._playlist
|
|
|
|
pw = self._playlist
|
|
|
|
if pw is not None and getattr(pw, "_tab_folder", False):
|
|
|
|
if pw is not None and getattr(pw, "_tab_folder", False):
|
|
|
|
name = self._active_tab_name()
|
|
|
|
name = self._active_tab_name()
|
|
|
@@ -5057,7 +5123,9 @@ class MainWindow(QMainWindow):
|
|
|
|
# a sibling ".../AlexisCrystal_copy", not a child ".../AlexisCrystal/_copy".
|
|
|
|
# a sibling ".../AlexisCrystal_copy", not a child ".../AlexisCrystal/_copy".
|
|
|
|
pw._dest_folder = (src_folder.rstrip("/" + os.sep) + "_copy") if src_folder else ""
|
|
|
|
pw._dest_folder = (src_folder.rstrip("/" + os.sep) + "_copy") if src_folder else ""
|
|
|
|
pw._tab_folder = getattr(src, "_tab_folder", False)
|
|
|
|
pw._tab_folder = getattr(src, "_tab_folder", False)
|
|
|
|
self._sync_folder_field_to_tab()
|
|
|
|
pw._mode = getattr(src, "_mode", "foley")
|
|
|
|
|
|
|
|
self._refresh_layout() # re-render tab titles (LTX2 badge)
|
|
|
|
|
|
|
|
self._on_active_pw_changed()
|
|
|
|
self._save_playlist_tabs()
|
|
|
|
self._save_playlist_tabs()
|
|
|
|
self._show_status(f"Duplicated tab → {label}", 4000)
|
|
|
|
self._show_status(f"Duplicated tab → {label}", 4000)
|
|
|
|
|
|
|
|
|
|
|
@@ -5066,6 +5134,22 @@ class MainWindow(QMainWindow):
|
|
|
|
f = self._spn_frames.value()
|
|
|
|
f = self._spn_frames.value()
|
|
|
|
self._lbl_frames_secs.setText(f"= {f / 25:.2f}s @25fps")
|
|
|
|
self._lbl_frames_secs.setText(f"= {f / 25:.2f}s @25fps")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _snap_frames_to_legal(self) -> None:
|
|
|
|
|
|
|
|
"""Snap a typed frame count to the nearest legal 8k+1 value.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Keeps the displayed value == the exported value, always legal. No-op
|
|
|
|
|
|
|
|
(and re-entrancy-safe) when the value is already legal.
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
cur = self._spn_frames.value()
|
|
|
|
|
|
|
|
legal = nearest_legal_frames(cur)
|
|
|
|
|
|
|
|
if legal != cur:
|
|
|
|
|
|
|
|
self._spn_frames.setValue(legal)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _on_active_pw_changed(self) -> None:
|
|
|
|
|
|
|
|
"""Re-sync everything that depends on which tab is active."""
|
|
|
|
|
|
|
|
self._sync_folder_field_to_tab()
|
|
|
|
|
|
|
|
self._apply_mode_to_controls()
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_mode_to_controls(self) -> None:
|
|
|
|
def _apply_mode_to_controls(self) -> None:
|
|
|
|
"""Show the length control matching the active tab's mode.
|
|
|
|
"""Show the length control matching the active tab's mode.
|
|
|
|
|
|
|
|
|
|
|
@@ -5372,9 +5456,8 @@ class MainWindow(QMainWindow):
|
|
|
|
if w is not None:
|
|
|
|
if w is not None:
|
|
|
|
self._active_pw = w
|
|
|
|
self._active_pw = w
|
|
|
|
w.set_filter(self._playlist_filter.text())
|
|
|
|
w.set_filter(self._playlist_filter.text())
|
|
|
|
self._sync_folder_field_to_tab()
|
|
|
|
self._on_active_pw_changed()
|
|
|
|
self._apply_playlist_filters()
|
|
|
|
self._apply_playlist_filters()
|
|
|
|
self._apply_mode_to_controls()
|
|
|
|
|
|
|
|
self._save_playlist_tabs()
|
|
|
|
self._save_playlist_tabs()
|
|
|
|
|
|
|
|
|
|
|
|
def _on_close_tab(self, idx: int) -> None:
|
|
|
|
def _on_close_tab(self, idx: int) -> None:
|
|
|
@@ -5468,7 +5551,7 @@ class MainWindow(QMainWindow):
|
|
|
|
if not self._active_pw._pinned:
|
|
|
|
if not self._active_pw._pinned:
|
|
|
|
self._playlist_tabs.setCurrentWidget(self._active_pw)
|
|
|
|
self._playlist_tabs.setCurrentWidget(self._active_pw)
|
|
|
|
self._active_pw.set_filter(self._playlist_filter.text())
|
|
|
|
self._active_pw.set_filter(self._playlist_filter.text())
|
|
|
|
self._sync_folder_field_to_tab()
|
|
|
|
self._on_active_pw_changed()
|
|
|
|
|
|
|
|
|
|
|
|
def _on_profile_activated(self, index: int) -> None:
|
|
|
|
def _on_profile_activated(self, index: int) -> None:
|
|
|
|
text = self._cmb_profile.itemText(index)
|
|
|
|
text = self._cmb_profile.itemText(index)
|
|
|
@@ -5522,7 +5605,7 @@ class MainWindow(QMainWindow):
|
|
|
|
self._playlist._select(0)
|
|
|
|
self._playlist._select(0)
|
|
|
|
self._refresh_markers()
|
|
|
|
self._refresh_markers()
|
|
|
|
self._update_status_perm()
|
|
|
|
self._update_status_perm()
|
|
|
|
self._sync_folder_field_to_tab()
|
|
|
|
self._on_active_pw_changed()
|
|
|
|
_log(f"Profile switched: {text}")
|
|
|
|
_log(f"Profile switched: {text}")
|
|
|
|
self._show_status(f"Profile: {text}", 3000)
|
|
|
|
self._show_status(f"Profile: {text}", 3000)
|
|
|
|
|
|
|
|
|
|
|
@@ -5559,17 +5642,17 @@ class MainWindow(QMainWindow):
|
|
|
|
# ── Subprofiles ──────────────────────────────────────────
|
|
|
|
# ── Subprofiles ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def _rebuild_subprofile_buttons(self):
|
|
|
|
def _rebuild_subprofile_buttons(self):
|
|
|
|
"""Recreate the per-subprofile export buttons in the transport row."""
|
|
|
|
"""Recreate the per-subprofile export buttons on the subprofile row."""
|
|
|
|
for btn in self._format_btns:
|
|
|
|
for btn in self._format_btns:
|
|
|
|
self._transport_row.removeWidget(btn)
|
|
|
|
|
|
|
|
btn.setParent(None)
|
|
|
|
btn.setParent(None)
|
|
|
|
self._format_btns.clear()
|
|
|
|
self._format_btns.clear()
|
|
|
|
for btn in self._subprofile_btns:
|
|
|
|
for btn in self._subprofile_btns:
|
|
|
|
self._transport_row.removeWidget(btn)
|
|
|
|
self._subprofile_row.removeWidget(btn)
|
|
|
|
btn.deleteLater()
|
|
|
|
btn.deleteLater()
|
|
|
|
self._subprofile_btns.clear()
|
|
|
|
self._subprofile_btns.clear()
|
|
|
|
# Find where to insert: right after the main Export button.
|
|
|
|
# Insert before the "+" add button (which sits before the trailing
|
|
|
|
anchor = self._transport_row.indexOf(self._btn_add_sub)
|
|
|
|
# stretch), so the buttons stay centered on the row.
|
|
|
|
|
|
|
|
anchor = self._subprofile_row.indexOf(self._btn_add_sub)
|
|
|
|
has_file = bool(self._file_path)
|
|
|
|
has_file = bool(self._file_path)
|
|
|
|
for i, name in enumerate(self._subprofiles):
|
|
|
|
for i, name in enumerate(self._subprofiles):
|
|
|
|
btn = QPushButton(f"▸ {name}")
|
|
|
|
btn = QPushButton(f"▸ {name}")
|
|
|
@@ -5577,7 +5660,7 @@ class MainWindow(QMainWindow):
|
|
|
|
btn.setToolTip(f"Export to folder_{name} (right-click to remove)")
|
|
|
|
btn.setToolTip(f"Export to folder_{name} (right-click to remove)")
|
|
|
|
btn.setEnabled(has_file)
|
|
|
|
btn.setEnabled(has_file)
|
|
|
|
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
|
|
|
|
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
|
|
|
|
self._transport_row.insertWidget(anchor + i, btn)
|
|
|
|
self._subprofile_row.insertWidget(anchor + i, btn)
|
|
|
|
self._subprofile_btns.append(btn)
|
|
|
|
self._subprofile_btns.append(btn)
|
|
|
|
self._rebuild_format_buttons()
|
|
|
|
self._rebuild_format_buttons()
|
|
|
|
# Keep the Edit ▸ Subprofiles ▸ Remove submenu in sync. Guarded because
|
|
|
|
# Keep the Edit ▸ Subprofiles ▸ Remove submenu in sync. Guarded because
|
|
|
@@ -5687,6 +5770,7 @@ class MainWindow(QMainWindow):
|
|
|
|
if paths:
|
|
|
|
if paths:
|
|
|
|
target = self._add_target_playlist()
|
|
|
|
target = self._add_target_playlist()
|
|
|
|
self._active_pw = target
|
|
|
|
self._active_pw = target
|
|
|
|
|
|
|
|
self._on_active_pw_changed()
|
|
|
|
target.add_files(paths)
|
|
|
|
target.add_files(paths)
|
|
|
|
self._apply_playlist_filters()
|
|
|
|
self._apply_playlist_filters()
|
|
|
|
self._save_playlist_tabs()
|
|
|
|
self._save_playlist_tabs()
|
|
|
@@ -5698,7 +5782,7 @@ class MainWindow(QMainWindow):
|
|
|
|
sender = self.sender()
|
|
|
|
sender = self.sender()
|
|
|
|
if isinstance(sender, PlaylistWidget) and sender in self._pws and sender is not self._active_pw:
|
|
|
|
if isinstance(sender, PlaylistWidget) and sender in self._pws and sender is not self._active_pw:
|
|
|
|
self._active_pw = sender
|
|
|
|
self._active_pw = sender
|
|
|
|
self._sync_folder_field_to_tab()
|
|
|
|
self._on_active_pw_changed()
|
|
|
|
elif isinstance(sender, PlaylistWidget) and sender in self._pws:
|
|
|
|
elif isinstance(sender, PlaylistWidget) and sender in self._pws:
|
|
|
|
self._active_pw = sender
|
|
|
|
self._active_pw = sender
|
|
|
|
if not os.path.isfile(path):
|
|
|
|
if not os.path.isfile(path):
|
|
|
@@ -5759,6 +5843,8 @@ class MainWindow(QMainWindow):
|
|
|
|
self._btn_play.setEnabled(True)
|
|
|
|
self._btn_play.setEnabled(True)
|
|
|
|
self._btn_pause.setEnabled(True)
|
|
|
|
self._btn_pause.setEnabled(True)
|
|
|
|
self._btn_export.setEnabled(True)
|
|
|
|
self._btn_export.setEnabled(True)
|
|
|
|
|
|
|
|
self._btn_extract_audio.setEnabled(True)
|
|
|
|
|
|
|
|
self._update_audio_region()
|
|
|
|
self._set_subprofile_btns_enabled(True)
|
|
|
|
self._set_subprofile_btns_enabled(True)
|
|
|
|
# Reset stale state from previous file
|
|
|
|
# Reset stale state from previous file
|
|
|
|
self._overwrite_path = ""
|
|
|
|
self._overwrite_path = ""
|
|
|
@@ -5852,7 +5938,7 @@ class MainWindow(QMainWindow):
|
|
|
|
self._playlist.set_folder_counts(folder_counts)
|
|
|
|
self._playlist.set_folder_counts(folder_counts)
|
|
|
|
self._playlist.set_disabled_paths(disabled_paths)
|
|
|
|
self._playlist.set_disabled_paths(disabled_paths)
|
|
|
|
# Profile-wide subcategory counts (exclude the main export folder).
|
|
|
|
# Profile-wide subcategory counts (exclude the main export folder).
|
|
|
|
base = os.path.basename(self._txt_folder.text())
|
|
|
|
base = os.path.basename(self._txt_folder.text().rstrip("/" + os.sep))
|
|
|
|
self._playlist.set_all_subcat_counts(
|
|
|
|
self._playlist.set_all_subcat_counts(
|
|
|
|
{f: c for f, c in all_counts.items() if f != base})
|
|
|
|
{f: c for f, c in all_counts.items() if f != base})
|
|
|
|
|
|
|
|
|
|
|
@@ -6099,7 +6185,7 @@ class MainWindow(QMainWindow):
|
|
|
|
if sub_btn.isHidden():
|
|
|
|
if sub_btn.isHidden():
|
|
|
|
continue
|
|
|
|
continue
|
|
|
|
suffix = sub_btn.text().removeprefix("▸ ")
|
|
|
|
suffix = sub_btn.text().removeprefix("▸ ")
|
|
|
|
sub_idx = self._transport_row.indexOf(sub_btn) + 1
|
|
|
|
sub_idx = self._subprofile_row.indexOf(sub_btn) + 1
|
|
|
|
for j, (label, ratio) in enumerate(formats):
|
|
|
|
for j, (label, ratio) in enumerate(formats):
|
|
|
|
btn = QPushButton(label)
|
|
|
|
btn = QPushButton(label)
|
|
|
|
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
|
|
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
|
@@ -6109,7 +6195,7 @@ class MainWindow(QMainWindow):
|
|
|
|
btn.clicked.connect(
|
|
|
|
btn.clicked.connect(
|
|
|
|
lambda _, s=suffix, r=ratio: self._on_export(
|
|
|
|
lambda _, s=suffix, r=ratio: self._on_export(
|
|
|
|
folder_suffix=s, force_ratio=r))
|
|
|
|
folder_suffix=s, force_ratio=r))
|
|
|
|
self._transport_row.insertWidget(sub_idx + j, btn)
|
|
|
|
self._subprofile_row.insertWidget(sub_idx + j, btn)
|
|
|
|
self._format_btns.append(btn)
|
|
|
|
self._format_btns.append(btn)
|
|
|
|
|
|
|
|
|
|
|
|
def _on_rand_toggle(self, _checked: bool = False) -> None:
|
|
|
|
def _on_rand_toggle(self, _checked: bool = False) -> None:
|
|
|
@@ -6291,6 +6377,7 @@ class MainWindow(QMainWindow):
|
|
|
|
self._cursor = t
|
|
|
|
self._cursor = t
|
|
|
|
dur = self._mpv.get_duration()
|
|
|
|
dur = self._mpv.get_duration()
|
|
|
|
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
|
|
|
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
|
|
|
|
|
|
|
self._update_audio_region()
|
|
|
|
self._preview_timer.start()
|
|
|
|
self._preview_timer.start()
|
|
|
|
if self._timeline._scan_mode:
|
|
|
|
if self._timeline._scan_mode:
|
|
|
|
self._scan_panel.highlight_time(t)
|
|
|
|
self._scan_panel.highlight_time(t)
|
|
|
@@ -6300,6 +6387,75 @@ class MainWindow(QMainWindow):
|
|
|
|
else:
|
|
|
|
else:
|
|
|
|
self._mpv.seek(t)
|
|
|
|
self._mpv.seek(t)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _on_audio_len_changed(self, value: float) -> None:
|
|
|
|
|
|
|
|
self._settings.setValue("audio_extract_len", value)
|
|
|
|
|
|
|
|
self._update_audio_region()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _update_audio_region(self) -> None:
|
|
|
|
|
|
|
|
"""Keep the timeline's audio-area band in sync with the playhead and
|
|
|
|
|
|
|
|
the audio-length control."""
|
|
|
|
|
|
|
|
if not self._file_path:
|
|
|
|
|
|
|
|
self._timeline.clear_audio_region()
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
start = self._cursor
|
|
|
|
|
|
|
|
self._timeline.set_audio_region(start, start + self._spn_audio_len.value())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _on_extract_audio(self) -> None:
|
|
|
|
|
|
|
|
"""Extract an exact-length audio slice starting at the playhead and
|
|
|
|
|
|
|
|
prompt for where to save it (format follows the chosen extension)."""
|
|
|
|
|
|
|
|
if not self._file_path:
|
|
|
|
|
|
|
|
self._show_status("Load a video first", 3000)
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
start = self._cursor
|
|
|
|
|
|
|
|
dur = self._spn_audio_len.value()
|
|
|
|
|
|
|
|
# No clamping: pass the requested length straight to ffmpeg. It stops
|
|
|
|
|
|
|
|
# cleanly at end-of-file if the source is shorter, and we report the
|
|
|
|
|
|
|
|
# actual length afterwards so any truncation is visible, not silent.
|
|
|
|
|
|
|
|
stem = os.path.splitext(os.path.basename(self._file_path))[0]
|
|
|
|
|
|
|
|
default_name = f"{stem}_{start:.2f}-{start + dur:.2f}s.wav"
|
|
|
|
|
|
|
|
default_dir = (self._settings.value("audio_extract_dir", "")
|
|
|
|
|
|
|
|
or self._tab_export_folder()
|
|
|
|
|
|
|
|
or os.path.dirname(self._file_path))
|
|
|
|
|
|
|
|
path, _sel = QFileDialog.getSaveFileName(
|
|
|
|
|
|
|
|
self, "Save audio clip", os.path.join(default_dir, default_name),
|
|
|
|
|
|
|
|
"WAV (*.wav);;MP3 (*.mp3);;FLAC (*.flac);;All files (*)")
|
|
|
|
|
|
|
|
if not path:
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
if not os.path.splitext(path)[1]:
|
|
|
|
|
|
|
|
path += ".wav"
|
|
|
|
|
|
|
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
|
|
|
|
|
|
cmd = build_audio_clip_command(self._file_path, start, dur, path)
|
|
|
|
|
|
|
|
self._btn_extract_audio.setEnabled(False)
|
|
|
|
|
|
|
|
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
|
|
|
|
|
|
self._show_status(f"Extracting {dur:.2f}s of audio…")
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
|
|
proc = None
|
|
|
|
|
|
|
|
err = str(e)
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
|
|
|
QApplication.restoreOverrideCursor()
|
|
|
|
|
|
|
|
self._btn_extract_audio.setEnabled(True)
|
|
|
|
|
|
|
|
if proc is not None and proc.returncode == 0 and os.path.exists(path):
|
|
|
|
|
|
|
|
self._settings.setValue("audio_extract_dir", os.path.dirname(path))
|
|
|
|
|
|
|
|
actual = probe_duration(path)
|
|
|
|
|
|
|
|
name = os.path.basename(path)
|
|
|
|
|
|
|
|
if actual is not None and actual < dur - 0.1:
|
|
|
|
|
|
|
|
self._show_status(
|
|
|
|
|
|
|
|
f"Saved {actual:.2f}s — source ended before {dur:.2f}s "
|
|
|
|
|
|
|
|
f"requested ({name})", 7000)
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
self._show_status(
|
|
|
|
|
|
|
|
f"Saved audio: {name} ({(actual or dur):.2f}s)", 5000)
|
|
|
|
|
|
|
|
_log(f"Audio extracted: {path} (requested {dur:.2f}s @ {start:.2f}s, "
|
|
|
|
|
|
|
|
f"actual {actual if actual is not None else '?'})")
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
err = (proc.stderr.strip().splitlines()[-1] if proc and proc.stderr
|
|
|
|
|
|
|
|
else (err if proc is None else "ffmpeg failed"))
|
|
|
|
|
|
|
|
self._show_status("Audio extract failed", 5000)
|
|
|
|
|
|
|
|
QMessageBox.warning(self, "Audio extract failed",
|
|
|
|
|
|
|
|
f"Could not extract audio:\n\n{err}")
|
|
|
|
|
|
|
|
|
|
|
|
def _toggle_play(self):
|
|
|
|
def _toggle_play(self):
|
|
|
|
if not self._file_path:
|
|
|
|
if not self._file_path:
|
|
|
|
return
|
|
|
|
return
|
|
|
@@ -6524,7 +6680,7 @@ class MainWindow(QMainWindow):
|
|
|
|
from PyQt6.QtWidgets import QMenu, QWidgetAction, QCheckBox, QWidget, QVBoxLayout, QPushButton, QHBoxLayout
|
|
|
|
from PyQt6.QtWidgets import QMenu, QWidgetAction, QCheckBox, QWidget, QVBoxLayout, QPushButton, QHBoxLayout
|
|
|
|
menu = QMenu(self)
|
|
|
|
menu = QMenu(self)
|
|
|
|
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
|
|
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
|
|
base = os.path.basename(self._txt_folder.text())
|
|
|
|
base = os.path.basename(self._txt_folder.text().rstrip("/" + os.sep))
|
|
|
|
counts = self._db.get_all_folder_counts(self._profile)
|
|
|
|
counts = self._db.get_all_folder_counts(self._profile)
|
|
|
|
folder_set: set[str] = set()
|
|
|
|
folder_set: set[str] = set()
|
|
|
|
# Subcategories from the current video's markers …
|
|
|
|
# Subcategories from the current video's markers …
|
|
|
@@ -6599,7 +6755,7 @@ class MainWindow(QMainWindow):
|
|
|
|
|
|
|
|
|
|
|
|
def _disable_all_subcats(self) -> None:
|
|
|
|
def _disable_all_subcats(self) -> None:
|
|
|
|
"""Disable every enabled subcategory at once (across all videos)."""
|
|
|
|
"""Disable every enabled subcategory at once (across all videos)."""
|
|
|
|
base = os.path.basename(self._txt_folder.text())
|
|
|
|
base = os.path.basename(self._txt_folder.text().rstrip("/" + os.sep))
|
|
|
|
counts = self._db.get_all_folder_counts(self._profile)
|
|
|
|
counts = self._db.get_all_folder_counts(self._profile)
|
|
|
|
folders = sorted(f for f, c in counts.items()
|
|
|
|
folders = sorted(f for f, c in counts.items()
|
|
|
|
if c and f != base and not f.endswith("_disabled"))
|
|
|
|
if c and f != base and not f.endswith("_disabled"))
|
|
|
@@ -6672,11 +6828,16 @@ class MainWindow(QMainWindow):
|
|
|
|
def _apply_subcat_visibility(self) -> None:
|
|
|
|
def _apply_subcat_visibility(self) -> None:
|
|
|
|
self._timeline._hidden_subcats = self._hidden_subcats
|
|
|
|
self._timeline._hidden_subcats = self._hidden_subcats
|
|
|
|
self._timeline.update()
|
|
|
|
self._timeline.update()
|
|
|
|
|
|
|
|
# Match the subcategory folder EXACTLY (same name the menu shows and
|
|
|
|
|
|
|
|
# _hidden_subcats stores: "<base>_<suffix>"). A fuzzy endswith() match
|
|
|
|
|
|
|
|
# let a ghost "_blowjob" (empty-base leftover) or an unrelated
|
|
|
|
|
|
|
|
# "mp4_no_clap" hide the wrong button, so enabling a subcategory never
|
|
|
|
|
|
|
|
# revealed its export button.
|
|
|
|
|
|
|
|
base = os.path.basename(self._txt_folder.text().rstrip("/" + os.sep))
|
|
|
|
for btn in self._subprofile_btns:
|
|
|
|
for btn in self._subprofile_btns:
|
|
|
|
suffix = btn.text().removeprefix("▸ ")
|
|
|
|
suffix = btn.text().removeprefix("▸ ")
|
|
|
|
visible = not any(f.endswith("_" + suffix) or f == suffix
|
|
|
|
folder = f"{base}_{suffix}" if base else suffix
|
|
|
|
for f in self._hidden_subcats)
|
|
|
|
btn.setVisible(folder not in self._hidden_subcats)
|
|
|
|
btn.setVisible(visible)
|
|
|
|
|
|
|
|
self._rebuild_format_buttons()
|
|
|
|
self._rebuild_format_buttons()
|
|
|
|
self._refresh_playlist_checks()
|
|
|
|
self._refresh_playlist_checks()
|
|
|
|
|
|
|
|
|
|
|
@@ -7252,7 +7413,11 @@ class MainWindow(QMainWindow):
|
|
|
|
return
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
spread = self._spn_spread.value()
|
|
|
|
spread = self._spn_spread.value()
|
|
|
|
clip_dur = self._clip_dur
|
|
|
|
# LTX-2 mode (active tab) sets the clip length from the exact frame
|
|
|
|
|
|
|
|
# count (F/25 s), not the Foley Duration spinbox — which is stale/hidden
|
|
|
|
|
|
|
|
# in LTX-2 mode. Computed here so span windowing uses the real length.
|
|
|
|
|
|
|
|
ltx2 = self._ltx2_export_params()
|
|
|
|
|
|
|
|
clip_dur = ltx2["duration"] if ltx2 is not None else self._clip_dur
|
|
|
|
groups = self._build_export_spans(
|
|
|
|
groups = self._build_export_spans(
|
|
|
|
regions, fuse_gap=self._spn_auto_fuse.value(),
|
|
|
|
regions, fuse_gap=self._spn_auto_fuse.value(),
|
|
|
|
spread=spread, min_dur=clip_dur,
|
|
|
|
spread=spread, min_dur=clip_dur,
|
|
|
@@ -7307,9 +7472,9 @@ class MainWindow(QMainWindow):
|
|
|
|
clip_duration = self._clip_dur
|
|
|
|
clip_duration = self._clip_dur
|
|
|
|
# LTX-2 mode (active tab) overrides length/resize and feeds the
|
|
|
|
# LTX-2 mode (active tab) overrides length/resize and feeds the
|
|
|
|
# 25fps / ÷32-crop / exact-frames params through to ffmpeg. Foley
|
|
|
|
# 25fps / ÷32-crop / exact-frames params through to ffmpeg. Foley
|
|
|
|
# tabs return None here and keep byte-identical behavior. Captured at
|
|
|
|
# tabs return None (see `ltx2` above) and keep byte-identical behavior.
|
|
|
|
# batch-build time so queued batches keep their own geometry.
|
|
|
|
# `ltx2` was captured at the top of this batch build so the windowing
|
|
|
|
ltx2 = self._ltx2_export_params()
|
|
|
|
# min_dur and the stashed geometry share one consistent length.
|
|
|
|
if ltx2 is not None:
|
|
|
|
if ltx2 is not None:
|
|
|
|
short_side = ltx2["short_side"]
|
|
|
|
short_side = ltx2["short_side"]
|
|
|
|
clip_duration = ltx2["duration"]
|
|
|
|
clip_duration = ltx2["duration"]
|
|
|
@@ -8097,6 +8262,7 @@ class MainWindow(QMainWindow):
|
|
|
|
if paths:
|
|
|
|
if paths:
|
|
|
|
target = self._add_target_playlist()
|
|
|
|
target = self._add_target_playlist()
|
|
|
|
self._active_pw = target
|
|
|
|
self._active_pw = target
|
|
|
|
|
|
|
|
self._on_active_pw_changed()
|
|
|
|
target.add_files(paths)
|
|
|
|
target.add_files(paths)
|
|
|
|
self._apply_playlist_filters()
|
|
|
|
self._apply_playlist_filters()
|
|
|
|
self._save_playlist_tabs()
|
|
|
|
self._save_playlist_tabs()
|
|
|
|