From ad9e56499162c3ac5919a4b418bec901ada7cb18 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 18 Jun 2026 15:10:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20LTX-2=20frames=20length=20control=20+?= =?UTF-8?q?=20route=2025fps/=C3=B732/exact-frames=20through=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- main.py | 95 ++++++++++++++++++++++++++++++++++++-- tests/test_ui_structure.py | 32 +++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 4c28e68..558330a 100755 --- a/main.py +++ b/main.py @@ -107,7 +107,10 @@ class ExportWorker(QThread): image_sequence: bool = False, max_workers: int | None = None, 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__() self._input = input_path self._jobs = jobs # [(start, output, portrait_ratio, crop_center), ...] @@ -116,6 +119,9 @@ class ExportWorker(QThread): self._max_workers = max_workers self._encoder = encoder 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._procs: list[subprocess.Popen] = [] self._procs_lock = __import__('threading').Lock() @@ -144,6 +150,9 @@ class ExportWorker(QThread): image_sequence=self._image_sequence, encoder=self._encoder, duration=self._duration, + target_fps=self._target_fps, + snap32=self._snap32, + frames=self._frames, ) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 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(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.setRange(1, 99) 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). self._load_playlist_tabs() self._apply_playlist_filters() + self._apply_mode_to_controls() if self._playlist is not None and self._playlist.count() > 0: self._playlist._select(0) @@ -4633,10 +4656,17 @@ class MainWindow(QMainWindow): # Row 4: separator — encode │ batch g.addWidget(self._group_sep(), 4, 0, 1, 7) # 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("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(self._lbl_frames_secs, 6, 2, 1, 2) g.addWidget(self._btn_reexport, 6, 5) g.setColumnStretch(6, 1) @@ -5015,6 +5045,46 @@ class MainWindow(QMainWindow): self._save_playlist_tabs() 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: pw = self._playlist_tabs.widget(idx) if pw is None: @@ -5022,6 +5092,7 @@ class MainWindow(QMainWindow): pw._mode = "ltx2" if getattr(pw, "_mode", "foley") != "ltx2" else "foley" self._refresh_layout() # re-render tab titles (badge) self._save_playlist_tabs() + self._apply_mode_to_controls() self._show_status(f"{pw._label}: {pw._mode.upper()} mode", 3000) def _tab_title(self, pw) -> str: @@ -5287,6 +5358,7 @@ class MainWindow(QMainWindow): w.set_filter(self._playlist_filter.text()) self._sync_folder_field_to_tab() self._apply_playlist_filters() + self._apply_mode_to_controls() self._save_playlist_tabs() def _on_close_tab(self, idx: int) -> None: @@ -7569,6 +7641,15 @@ class MainWindow(QMainWindow): ] 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. # 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_format = fmt 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_folder = folder self._export_folder_suffix = folder_suffix @@ -7601,14 +7682,18 @@ class MainWindow(QMainWindow): # (typically 3–5 on consumer NVIDIA cards), so cap workers. 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}, " - 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._file_path, jobs, short_side=short_side, image_sequence=image_sequence, max_workers=max_workers, 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.all_done.connect(self._on_batch_done) diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 7e68fd5..a616250 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -158,3 +158,35 @@ def test_tab_mode_toggle(win): win._loading_tabs = False assert win._pws[0]._mode == "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()