feat: LTX-2 frames length control + route 25fps/÷32/exact-frames through export

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 15:10:07 +02:00
parent 4baac54930
commit ad9e564991
2 changed files with 122 additions and 5 deletions
+90 -5
View File
@@ -107,7 +107,10 @@ class ExportWorker(QThread):
image_sequence: bool = False, image_sequence: bool = False,
max_workers: int | None = None, max_workers: int | None = None,
encoder: str = "libx264", encoder: str = "libx264",
duration: float = 8.0): duration: float = 8.0,
target_fps: float | None = None,
snap32: bool = False,
frames: int | None = None):
super().__init__() super().__init__()
self._input = input_path self._input = input_path
self._jobs = jobs # [(start, output, portrait_ratio, crop_center), ...] self._jobs = jobs # [(start, output, portrait_ratio, crop_center), ...]
@@ -116,6 +119,9 @@ class ExportWorker(QThread):
self._max_workers = max_workers self._max_workers = max_workers
self._encoder = encoder self._encoder = encoder
self._duration = duration self._duration = duration
self._target_fps = target_fps # LTX-2: force output fps (None = source)
self._snap32 = snap32 # LTX-2: crop W/H down to ÷32
self._frames = frames # LTX-2: exact video frame count
self._cancel = False self._cancel = False
self._procs: list[subprocess.Popen] = [] self._procs: list[subprocess.Popen] = []
self._procs_lock = __import__('threading').Lock() self._procs_lock = __import__('threading').Lock()
@@ -144,6 +150,9 @@ class ExportWorker(QThread):
image_sequence=self._image_sequence, image_sequence=self._image_sequence,
encoder=self._encoder, encoder=self._encoder,
duration=self._duration, duration=self._duration,
target_fps=self._target_fps,
snap32=self._snap32,
frames=self._frames,
) )
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
with self._procs_lock: with self._procs_lock:
@@ -4140,6 +4149,19 @@ class MainWindow(QMainWindow):
self._spn_clip_dur.valueChanged.connect(lambda: self._preview_timer.start()) self._spn_clip_dur.valueChanged.connect(lambda: self._preview_timer.start())
self._spn_clip_dur.valueChanged.connect(self._update_play_loop) self._spn_clip_dur.valueChanged.connect(self._update_play_loop)
# LTX-2 frame-count length control (soft preset; F % 8 == 1 when stepped
# by 8 from 9). Shown only on ltx2-mode tabs via _apply_mode_to_controls.
self._spn_frames = QSpinBox()
self._spn_frames.setRange(9, 100000)
self._spn_frames.setSingleStep(8)
self._spn_frames.setValue(201)
self._spn_frames.setSuffix(" f")
self._spn_frames.setToolTip("LTX-2 frame count (F % 8 == 1)")
self._lbl_frames_secs = QLabel()
self._lbl_frames_secs.setToolTip("Clip length at 25 fps")
self._spn_frames.valueChanged.connect(self._update_frames_secs_label)
self._update_frames_secs_label()
self._spn_clips = QSpinBox() self._spn_clips = QSpinBox()
self._spn_clips.setRange(1, 99) self._spn_clips.setRange(1, 99)
self._spn_clips.setToolTip("Number of overlapping clips per export") self._spn_clips.setToolTip("Number of overlapping clips per export")
@@ -4544,6 +4566,7 @@ class MainWindow(QMainWindow):
# Resume last session: rebuild file-list tabs (per-profile). # Resume last session: rebuild file-list tabs (per-profile).
self._load_playlist_tabs() self._load_playlist_tabs()
self._apply_playlist_filters() self._apply_playlist_filters()
self._apply_mode_to_controls()
if self._playlist is not None and self._playlist.count() > 0: if self._playlist is not None and self._playlist.count() > 0:
self._playlist._select(0) self._playlist._select(0)
@@ -4633,10 +4656,17 @@ class MainWindow(QMainWindow):
# Row 4: separator — encode │ batch # Row 4: separator — encode │ batch
g.addWidget(self._group_sep(), 4, 0, 1, 7) g.addWidget(self._group_sep(), 4, 0, 1, 7)
# Row 5/6: batch params + actions # Row 5/6: batch params + actions
g.addWidget(QLabel("Duration:"), 5, 0); g.addWidget(self._spn_clip_dur, 5, 1) self._lbl_duration = QLabel("Duration:")
g.addWidget(self._lbl_duration, 5, 0); g.addWidget(self._spn_clip_dur, 5, 1)
# LTX-2 frames length control reuses the Duration row's label+spinbox
# cells; only one of the two is shown at a time (see
# _apply_mode_to_controls). Its read-out sits in the free cell on row 6.
self._lbl_frames = QLabel("Frames:")
g.addWidget(self._lbl_frames, 5, 0); g.addWidget(self._spn_frames, 5, 1)
g.addWidget(QLabel("Clips:"), 5, 2); g.addWidget(self._spn_clips, 5, 3) g.addWidget(QLabel("Clips:"), 5, 2); g.addWidget(self._spn_clips, 5, 3)
g.addWidget(QLabel("Spread:"), 5, 4); g.addWidget(self._spn_spread, 5, 5) g.addWidget(QLabel("Spread:"), 5, 4); g.addWidget(self._spn_spread, 5, 5)
g.addWidget(QLabel("Workers:"), 6, 0); g.addWidget(self._spn_workers, 6, 1) g.addWidget(QLabel("Workers:"), 6, 0); g.addWidget(self._spn_workers, 6, 1)
g.addWidget(self._lbl_frames_secs, 6, 2, 1, 2)
g.addWidget(self._btn_reexport, 6, 5) g.addWidget(self._btn_reexport, 6, 5)
g.setColumnStretch(6, 1) g.setColumnStretch(6, 1)
@@ -5015,6 +5045,46 @@ class MainWindow(QMainWindow):
self._save_playlist_tabs() self._save_playlist_tabs()
self._show_status(f"Duplicated tab → {label}", 4000) self._show_status(f"Duplicated tab → {label}", 4000)
def _update_frames_secs_label(self) -> None:
"""Refresh the LTX-2 read-out (= F/25 s @25fps) from _spn_frames."""
f = self._spn_frames.value()
self._lbl_frames_secs.setText(f"= {f / 25:.2f}s @25fps")
def _apply_mode_to_controls(self) -> None:
"""Show the length control matching the active tab's mode.
ltx2 frames spinbox + read-out (Duration hidden); foley Duration.
Guarded for early calls before the widgets exist.
"""
if not hasattr(self, "_spn_frames") or not hasattr(self, "_spn_clip_dur"):
return
pw = self._playlist
is_ltx2 = pw is not None and getattr(pw, "_mode", "foley") == "ltx2"
self._spn_frames.setVisible(is_ltx2)
self._lbl_frames_secs.setVisible(is_ltx2)
if hasattr(self, "_lbl_frames"):
self._lbl_frames.setVisible(is_ltx2)
self._spn_clip_dur.setVisible(not is_ltx2)
if hasattr(self, "_lbl_duration"):
self._lbl_duration.setVisible(not is_ltx2)
if is_ltx2 and self._spn_resize.value() == 0:
self._spn_resize.setValue(512) # LTX-2 default short side
def _ltx2_export_params(self) -> dict | None:
"""Return LTX-2 ffmpeg kwargs for the active tab, or None for Foley."""
pw = self._playlist
if pw is None or getattr(pw, "_mode", "foley") != "ltx2":
return None
frames = int(self._spn_frames.value())
fps = 25.0
return {
"target_fps": fps,
"snap32": True,
"frames": frames,
"duration": frames / fps,
"short_side": self._spn_resize.value() or 512,
}
def _on_tab_mode_toggle(self, idx: int) -> None: def _on_tab_mode_toggle(self, idx: int) -> None:
pw = self._playlist_tabs.widget(idx) pw = self._playlist_tabs.widget(idx)
if pw is None: if pw is None:
@@ -5022,6 +5092,7 @@ class MainWindow(QMainWindow):
pw._mode = "ltx2" if getattr(pw, "_mode", "foley") != "ltx2" else "foley" pw._mode = "ltx2" if getattr(pw, "_mode", "foley") != "ltx2" else "foley"
self._refresh_layout() # re-render tab titles (badge) self._refresh_layout() # re-render tab titles (badge)
self._save_playlist_tabs() self._save_playlist_tabs()
self._apply_mode_to_controls()
self._show_status(f"{pw._label}: {pw._mode.upper()} mode", 3000) self._show_status(f"{pw._label}: {pw._mode.upper()} mode", 3000)
def _tab_title(self, pw) -> str: def _tab_title(self, pw) -> str:
@@ -5287,6 +5358,7 @@ class MainWindow(QMainWindow):
w.set_filter(self._playlist_filter.text()) w.set_filter(self._playlist_filter.text())
self._sync_folder_field_to_tab() self._sync_folder_field_to_tab()
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:
@@ -7569,6 +7641,15 @@ class MainWindow(QMainWindow):
] ]
short_side = self._spn_resize.value() or None short_side = self._spn_resize.value() or None
duration = self._clip_dur
# LTX-2 mode (active tab) overrides length/resize and feeds the
# 25fps / ÷32-crop / exact-frames params through to ffmpeg. Foley
# tabs return None here and keep byte-identical behavior.
ltx2 = self._ltx2_export_params()
if ltx2 is not None:
short_side = ltx2["short_side"]
duration = ltx2["duration"]
# Stash export config for _on_clip_done DB writes. # Stash export config for _on_clip_done DB writes.
# Cursor is frozen here — user may move it during async export. # Cursor is frozen here — user may move it during async export.
@@ -7578,7 +7659,7 @@ class MainWindow(QMainWindow):
self._export_crop_center = self._crop_center self._export_crop_center = self._crop_center
self._export_format = fmt self._export_format = fmt
self._export_clip_count = self._spn_clips.value() self._export_clip_count = self._spn_clips.value()
self._export_clip_duration = self._clip_dur self._export_clip_duration = duration
self._export_spread = self._spn_spread.value() self._export_spread = self._spn_spread.value()
self._export_folder = folder self._export_folder = folder
self._export_folder_suffix = folder_suffix self._export_folder_suffix = folder_suffix
@@ -7601,14 +7682,18 @@ class MainWindow(QMainWindow):
# (typically 35 on consumer NVIDIA cards), so cap workers. # (typically 35 on consumer NVIDIA cards), so cap workers.
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value() max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
_log(f"Export: {len(jobs)} clip(s), encoder={encoder}, workers={max_workers}, " _log(f"Export: {len(jobs)} clip(s), encoder={encoder}, workers={max_workers}, "
f"resize={short_side}, format={fmt}") f"resize={short_side}, format={fmt}"
+ (f", ltx2 frames={ltx2['frames']}@{ltx2['target_fps']:g}fps" if ltx2 else ""))
self._export_worker = ExportWorker( self._export_worker = ExportWorker(
self._file_path, jobs, self._file_path, jobs,
short_side=short_side, short_side=short_side,
image_sequence=image_sequence, image_sequence=image_sequence,
max_workers=max_workers, max_workers=max_workers,
encoder=encoder, encoder=encoder,
duration=self._clip_dur, duration=duration,
target_fps=ltx2["target_fps"] if ltx2 else None,
snap32=ltx2["snap32"] if ltx2 else False,
frames=ltx2["frames"] if ltx2 else None,
) )
self._export_worker.finished.connect(self._on_clip_done) self._export_worker.finished.connect(self._on_clip_done)
self._export_worker.all_done.connect(self._on_batch_done) self._export_worker.all_done.connect(self._on_batch_done)
+32
View File
@@ -158,3 +158,35 @@ def test_tab_mode_toggle(win):
win._loading_tabs = False win._loading_tabs = False
assert win._pws[0]._mode == "ltx2" assert win._pws[0]._mode == "ltx2"
assert win._tab_title(win._pws[0]).endswith("[LTX2]") assert win._tab_title(win._pws[0]).endswith("[LTX2]")
def test_ltx2_params_none_for_foley(win):
# A Foley tab feeds no LTX-2 ffmpeg params into export. Set the mode
# explicitly: a prior test's closeEvent can persist an ltx2 tab into the
# shared (throwaway) QSettings, so don't rely on the loaded default here.
win._playlist._mode = "foley"
assert win._ltx2_export_params() is None
def test_ltx2_params_for_ltx2_tab(win):
# An ltx2-mode active tab: _ltx2_export_params returns the 25fps / ÷32 /
# exact-frames kwargs, and _apply_mode_to_controls swaps the length control
# (Duration hidden, frames shown). short_side defaults to 512 when unset.
win._spn_resize.setValue(0) # force the 512 LTX-2 default path
win._pws[0]._mode = "ltx2"
win._active_pw = win._pws[0]
win._playlist_tabs.setCurrentWidget(win._pws[0])
win._spn_frames.setValue(201)
win._apply_mode_to_controls()
assert win._ltx2_export_params() == {
"target_fps": 25.0,
"snap32": True,
"frames": 201,
"duration": 201 / 25,
"short_side": 512,
}
# In offscreen, isVisibleTo(win) may be False for both; assert via the
# show/hide flag that the Duration control is hidden in ltx2 mode.
assert win._spn_clip_dur.isHidden()
assert not win._spn_frames.isHidden()