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>
This commit is contained in:
@@ -40,6 +40,7 @@ from core.ffmpeg import (
|
|||||||
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"
|
||||||
|
|
||||||
@@ -4160,6 +4161,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()
|
||||||
@@ -5057,7 +5059,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 +5070,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 +5392,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 +5487,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 +5541,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)
|
||||||
|
|
||||||
@@ -5687,6 +5706,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 +5718,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):
|
||||||
@@ -7252,7 +7272,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 +7331,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 +8121,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()
|
||||||
|
|||||||
@@ -190,3 +190,26 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user