6 Commits

Author SHA1 Message Date
Ethanfel eab5c690c7 feat: audio area length — remove the upper cap + step by 1s
The audio extract length is meant for visualizing/grabbing sequences that can
run minutes long, but the control capped it and stepped in fiddly 0.10s
increments. Raise the range to effectively unlimited (24h; ffmpeg stops cleanly
at end-of-file if the source is shorter) and make the arrows step 1s — typing
still allows sub-second precision. Widen the field for the larger values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 01:11:57 +02:00
Ethanfel 4445f0e7f4 fix: audio extract honored a silent length clamp — 30s near the end became 3s
_on_extract_audio clamped the duration to (timeline_duration - cursor), so with
the playhead within the requested length of the end (or any under-reported
duration) a 30s request was silently truncated to whatever remained — the user
asked for 30s and got 3s with no indication why.

Drop the clamp: pass the requested length straight to ffmpeg, which stops
cleanly at end-of-file if the source is shorter. Then ffprobe the result and,
when it comes up short, say so ("Saved 3.0s — source ended before 30.0s
requested") instead of silently shrinking. When there's room, 30s now yields
exactly 30s.

Adds core.ffmpeg.probe_duration(). Verified end-to-end: a fitting request
returns the exact length; a genuine near-end request returns the available
audio (rc=0) and is reported as truncated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:07:35 +02:00
Ethanfel ed63d04abf feat: Extract audio area — exact-length audio slice from the playhead, save-as
A dedicated "♪ Extract audio" button on the transport row grabs an exact
length of audio (set via the adjacent length box, from the playhead) and opens
a Save As dialog. Output format follows the chosen extension — WAV (pcm_s16le),
MP3 (libmp3lame), FLAC, m4a/aac, ogg/opus — re-encoding as needed; unknown
extensions let ffmpeg pick from the container.

- core.ffmpeg.build_audio_clip_command(input, start, duration, out_path):
  fast-seek + exact -t duration + -vn, codec by extension. Verified end-to-end
  (wav/mp3/flac all land at exactly the requested duration).
- Timeline shows the audio area as a distinct teal dashed band spanning
  [cursor, cursor+length], updated live as the playhead or length changes, so
  you see exactly what will be extracted.
- Length + last save dir persist in QSettings; button enabled once a file loads.

Tests: 3 core (codec-by-extension, exact length, case-insensitive) + 2 GUI
(controls exist, band tracks cursor/length).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:48:24 +02:00
Ethanfel 7ae1720b9e fix: subcategory export buttons hidden by ghost entries + give them their own centered row
Two issues with the per-subprofile (subcategory) export buttons:

1. Visibility was decided by a fuzzy `f.endswith("_" + suffix)` match against
   the hidden-subcats set. A ghost "_blowjob" (empty-base leftover from the
   trailing-slash folder bug) or an unrelated "mp4_no_clap" would match and
   hide the wrong button — so enabling a subcategory in the Sub menu never
   revealed its export button. Match the exact "<base>_<suffix>" folder name
   instead (same name the menu shows and _hidden_subcats stores).

2. The buttons were crammed into the transport row after Export. Move them to
   their own row with stretches on both ends so the (often many) "▸ name"
   buttons stay centered and out of the transport controls.

Also cleared the polluted hidden_subcats/POV_Front set in the user's QSettings
(ghost "_*" names + a hide-all'd set of real "mp4_*"), so every subcategory is
visible again. Regression test added for the exact-match predicate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:19:20 +02:00
Ethanfel 514607eddd fix: harden export-folder base derivation against a trailing slash
A folder ending in "/" made os.path.basename() return "", so subprofile
folders/labels became "_blowjob" instead of "mp4_blowjob" — cluttering the
subcategory menu and breaking the marker↔category match. rstrip the trailing
separator in _tab_export_folder and the three basename(_txt_folder) sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 13:54:32 +02:00
Ethanfel 4299de5f97 fix: keep length control + mode in sync on every active-tab switch; dup preserves LTX-2; auto-export + frames use legal LTX-2 length
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 15:35:46 +02:00
4 changed files with 346 additions and 32 deletions
+45
View File
@@ -173,6 +173,51 @@ def build_audio_extract_command(input_path: str, start: float, sequence_dir: str
] ]
# 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]: def detect_hw_encoders() -> list[str]:
"""Probe ffmpeg for available H.264 hardware encoders. """Probe ffmpeg for available H.264 hardware encoders.
+198 -32
View File
@@ -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()
+81
View File
@@ -190,3 +190,84 @@ def test_ltx2_params_for_ltx2_tab(win):
# show/hide flag that the Duration control is hidden in ltx2 mode. # show/hide flag that the Duration control is hidden in ltx2 mode.
assert win._spn_clip_dur.isHidden() assert win._spn_clip_dur.isHidden()
assert not win._spn_frames.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
+22
View File
@@ -1,5 +1,6 @@
import tempfile, os, json 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 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 core.annotations import build_annotation_json_path, upsert_clip_annotation
from main import ProcessedDB from main import ProcessedDB
@@ -54,6 +55,27 @@ def test_ffmpeg_command_with_resize():
assert cmd[-1] == "/out/clip_001.mp4" 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 --- # --- ProcessedDB ---
def test_db_add_and_get_markers(): def test_db_add_and_get_markers():