Compare commits

9 Commits

Author SHA1 Message Date
Ethanfel 86ab606059 docs: changelog + README for LTX-2 mode + tab features (v1.2)
Bump APP_VERSION to 1.2 and add a 1.2 changelog entry covering the
per-tab export folder + mismatch guardrail, Duplicate tab, and LTX-2
export mode. README Interface section gains matching bullets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 15:16:18 +02:00
Ethanfel 87ccd8650c feat: honor LTX-2 mode in re-export and auto-export
Mirror the manual export path: re-export and auto-export now read the
active tab's LTX-2 params via _ltx2_export_params() and override
short_side/duration plus thread target_fps/snap32/frames through to
ExportWorker. Foley tabs return None and keep byte-identical behavior.
For auto-export, params are captured at batch-build time so queued
batches keep their own geometry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 15:16:02 +02:00
Ethanfel ad9e564991 feat: LTX-2 frames length control + route 25fps/÷32/exact-frames through export
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 15:10:07 +02:00
Ethanfel 4baac54930 feat: per-tab LTX-2 mode toggle + [LTX2] badge (pipeline wiring in next stage)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 15:03:32 +02:00
Ethanfel 879684ce25 fix: audio extract duration for LTX-2 frame-exact clips
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:58:56 +02:00
Ethanfel 92774216d4 feat: LTX-2 ffmpeg params (target_fps, snap32, frames)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:58:50 +02:00
Ethanfel 02fd0f0919 feat: LTX-2 legal-frame helpers (core/ltx2.py)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:58:44 +02:00
Ethanfel c537ac678d docs: LTX-2 per-tab export mode implementation plan
6 stages: core/ltx2 frame math (TDD), ffmpeg target_fps/snap32/frames (TDD),
per-tab _mode, tab duplicate/convert menu, length-control swap + export wiring,
finalize. Builds on tab-export-folder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:55:05 +02:00
Ethanfel 755f7e5131 docs: LTX-2 per-tab export mode design
Per-tab foley|ltx2 pipeline mode + "Duplicate as LTX-2". LTX-2: frame-exact
length (F%8==1), force 25fps, center-crop to ÷32. Soft preset, builds on the
per-tab export folder feature. core/ffmpeg gains optional target_fps/snap32.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:55:05 +02:00
8 changed files with 560 additions and 10 deletions
+3
View File
@@ -66,6 +66,9 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
- **Menu bar** — File / Edit / Scan / View / Help hold the occasional actions (open files, train, scan all, profiles); the profile selector and shortcuts (`?`) sit in the top-right corner
- **Control deck** — a compact tabbed panel under the video groups the settings into **Export** (label, name, folder, format, resize, duration/clips/spread, workers), **Crop & Track**, and **Scan** (model, threshold, fuse, scan/auto/speech/review)
- **Side-by-side panels** — pin deck panels to view them as resizable columns: right-click a deck tab → *Show side-by-side*, or toggle them under *View ▸ Side-by-side panels*; drag the dividers to reallocate space, and the layout persists between sessions
- **Per-tab export folder** — each file-list tab remembers its own output folder; switching tabs follows that tab's folder, and a guardrail warns when the loaded video doesn't match the destination
- **Duplicate tab** — right-click a file-list tab → *Duplicate tab* to clone its files into a new tab with its own export folder
- **LTX-2 export mode** — per-tab **Foley | LTX-2** toggle (right-click a tab, shown with an `[LTX2]` badge): LTX-2 clips are frame-exact (`frames % 8 == 1`), forced to 25 fps, and center-cropped so width & height are divisible by 32 — for LTX-2 video-to-audio datasets; applies to manual, re-export, and auto-export
- **Status bar** — export/scan progress and messages, with the current file · profile · worker count always shown
## Keyboard shortcuts
+16
View File
@@ -79,6 +79,9 @@ def build_ffmpeg_command(
image_sequence: bool = False,
encoder: str = "libx264",
duration: float = 8.0,
target_fps: float | None = None,
snap32: bool = False,
frames: int | None = None,
) -> list[str]:
# -ss before -i: fast input-seeking. Safe here because we always re-encode,
# so there is no keyframe-alignment issue from pre-input seek.
@@ -109,6 +112,13 @@ def build_ffmpeg_command(
f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})':flags=lanczos"
)
# LTX-2: centered crop to ÷32 (no rescale → no aspect distortion) then fps.
# Placed among CPU filters, after scale and before the VAAPI hwupload block.
if snap32:
filters.append("crop=trunc(iw/32)*32:trunc(ih/32)*32")
if target_fps is not None:
filters.append(f"fps={target_fps:g}")
# VAAPI: decoded frames are GPU surfaces. CPU filters need hwdownload first.
if use_hw_vaapi:
if filters:
@@ -120,6 +130,12 @@ def build_ffmpeg_command(
if filters:
cmd += ["-vf", ",".join(filters)]
# LTX-2 output rate + exact frame cap (apply to both clip and webp-seq paths).
if target_fps is not None:
cmd += ["-r", f"{target_fps:g}"]
if frames is not None:
cmd += ["-frames:v", str(frames)]
if image_sequence:
cmd += [
"-an",
+26
View File
@@ -0,0 +1,26 @@
"""LTX-2 frame-count math. Legal F satisfy F % 8 == 1 (8x temporal + 1)."""
def is_legal_frames(f: int) -> bool:
return f >= 9 and f % 8 == 1
def legal_frames(min_f: int = 9, max_f: int = 1000) -> list[int]:
start = max(9, min_f + ((1 - min_f) % 8)) # first 8k+1 >= min_f
return list(range(start, max_f + 1, 8))
def nearest_legal_frames(f: int) -> int:
if f <= 9:
return 9
low = ((f - 1) // 8) * 8 + 1
high = low + 8
return low if (f - low) <= (high - f) else high
def duration_for_frames(frames: int, fps: float) -> float:
return frames / fps
def frames_for_duration(duration: float, fps: float) -> int:
return nearest_legal_frames(round(duration * fps))
@@ -0,0 +1,66 @@
# LTX-2 per-tab export mode — Design
**Goal:** Add an export *pipeline mode* to each file-list tab — **Foley** (current behavior) or **LTX-2** — so the same source videos can feed both a Foley dataset (8 s clips) and an LTX-2 V2A dataset (frame-exact, ÷32, 25 fps) without the two ever mixing.
**Depends on:** the per-tab export folder feature (branch `tab-export-folder`) — this design extends that per-tab state. Implementation branch `ltx2-preset` is based on it.
**Scope:** soft preset (no hard enforcement — defaults are LTX-2-legal but every control stays editable). `core/` gains optional pipeline params; Foley path is byte-for-byte unchanged.
---
## LTX-2 constraints (why this exists)
LTX-2 (32× spatial VAE, 8× temporal + 1) requires, for a clip:
- **W and H each divisible by 32.**
- **Frame count F such that `F % 8 == 1`** → 9, 17, 25, … 201, … (transformer seq-len ∝ `(W/32)·(H/32)·((F1)/8+1)`).
- **fps** only sets real duration `F/fps`; for V2A it fixes the paired-audio length and audio↔motion sync, so it must be **consistent across the dataset and equal to the inference `frame_rate`**. Target: **25 fps**.
- V2A video is frozen conditioning → low spatial res (384512) is fine and cheaper.
Note: 8 s @ 25 fps = 200 frames, and `200 % 8 == 0`**8 s is not legal**. Nearest legal: F=193 (7.72 s) or **F=201 (8.04 s)**.
---
## Model: per-tab mode
Each tab (`PlaylistWidget`) gains `_mode ∈ {"foley","ltx2"}`, persisted alongside `_dest_folder`/`_pinned`/`_tab_folder` in `_save_playlist_tabs`/`_load_playlist_tabs`. Default `"foley"` → existing tabs load unchanged. The **active tab's mode drives the export pipeline and the length control.**
### Tab context menu (`_DeckTabBar`/`_PlaylistTabBar`)
- **Duplicate as LTX-2** — headline action: clone the tab's file list + separators into a new tab; set `mode="ltx2"`; derive a separate export folder `"<dest_folder>_ltx2"`; load LTX-2 default geometry. Lets you spin an LTX-2 dataset off a Foley working set.
- **Duplicate tab** — clone keeping the same mode.
- **LTX-2 mode** — checkable, flips an existing tab between foley/ltx2.
- Tab label shows a small **`[LTX2]`** badge when `mode=="ltx2"`.
## What `ltx2` mode changes (soft — still editable)
| Aspect | Foley | LTX-2 |
|--------|-------|-------|
| Clip length | Duration spinbox (seconds) | **Frame-count F** control stepping the legal series (9, 17, …, 201, …); shows `= F/25 s` |
| Output fps | inherits source | **forced 25 fps** (resample; preserves duration/sync) |
| Output W×H | short-side resize → even long side | **center-cropped to ÷32** on both axes (no aspect distortion; loses ≤31 px/side); resize default **512** |
| Frame exactness | duration-based | exactly **F** frames (`-frames:v F`) |
Defaults loaded on convert: resize **512**, **F = 201** (≈8.04 s, mirrors the 8 s Foley clips), ratio as set. All editable afterward.
## Pipeline (`core/ffmpeg.build_ffmpeg_command`)
Add optional params; Foley calls pass none → identical output to today:
- `target_fps: float | None` — when set, append `fps={target_fps}` filter and `-r {target_fps}`.
- `snap32: bool` — when true, after the scale append a centered crop to the nearest lower multiple of 32 on each axis: `crop=trunc(iw/32)*32:trunc(ih/32)*32`.
- Frame-exact length: caller computes `duration = F/target_fps` and passes `-frames:v F` on the video output so the clip has exactly F frames; audio extract uses the same `F/target_fps` duration so V2A pairing stays aligned.
Filter order: portrait-crop (aspect) → scale (short side, ÷32 default) → snap32 crop → fps. The snap32 center-crop runs after scaling so the ÷32 trim is on final pixels.
## UI wiring (`MainWindow`)
- The length spinbox area swaps with the active tab's mode: Foley shows *Duration (s)*; LTX-2 shows *Frames (F)* with a live `= s @25fps` readout. Switching tabs (or toggling mode) reconfigures it; uses the existing `_sync_folder_field_to_tab`-style sync hook on tab change.
- `_on_export` / `_start_export_batch`: when the active tab is `ltx2`, pass `target_fps=25`, `snap32=True`, and frame-exact length to the ffmpeg builder; otherwise unchanged.
- The mismatch guardrail (just added) and per-tab folder continue to apply.
## Persistence & migration
`_mode` added to each tab's saved JSON (default `"foley"` when absent). No DB changes. Existing sessions load every tab as Foley → zero behavior change until a tab is converted.
## What this does NOT do
- No hard enforcement: you can set an illegal F or non-÷32 resize manually; the pipeline still crops to ÷32 and uses whatever F you pick (the *control* defaults/steps keep you legal, but nothing blocks you).
- No motion interpolation on fps resample (frame drop/dup only); keep sources native 25 fps where possible.
- No change to Foley exports, the scan pipeline, or the DB schema.
- No automatic re-export of existing clips into LTX-2 — you cut LTX-2 clips in the converted tab.
@@ -0,0 +1,179 @@
# LTX-2 per-tab export mode — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a per-tab export pipeline mode (Foley | LTX-2) so the same videos can feed both an 8 s Foley dataset and a frame-exact, ÷32, 25 fps LTX-2 V2A dataset, with a "Duplicate as LTX-2" tab action.
**Architecture:** `core/ffmpeg.build_ffmpeg_command` gains optional `target_fps` / `snap32` / `frames` params (Foley path unchanged); a tiny `core/ltx2.py` holds the legal-frame math. `PlaylistWidget` gains `_mode`; the tab menu gains duplicate/convert actions; the length control + `_on_export` wiring switch on the active tab's mode. Soft preset — defaults are legal, everything stays editable.
**Tech Stack:** Python 3.11+, PyQt6, ffmpeg, pytest. Branch `ltx2-preset` (based on `tab-export-folder`). Design: `docs/plans/2026-06-18-ltx2-preset-design.md`.
---
## Conventions
- **Core (`core/ffmpeg.py`, `core/ltx2.py`) is real TDD** — pure functions tested in `tests/test_utils.py` style. Run: `LD_PRELOAD=/usr/lib/libstdc++.so.6 python -m pytest tests/test_utils.py -q` (the preload is needed because importing `main` pulls `mpv`; see `project_qt_test_env`). 3 pre-existing failures there are unrelated — don't count them.
- **GUI parts** verified by the offscreen structure test (`LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v`) plus a **manual launch** (`./8cut.sh`).
- Line numbers are starting anchors; locate by symbol. Commit per task. Co-author trailer on every commit:
`Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
---
## Stage 1 — LTX-2 math (`core/ltx2.py`) [TDD]
### Task 1.1: legal-frame helpers
**Files:** Create `core/ltx2.py`; Test in `tests/test_utils.py` (append).
**Step 1 — failing tests** (append to `tests/test_utils.py`):
```python
from core.ltx2 import is_legal_frames, nearest_legal_frames, frames_for_duration, duration_for_frames, legal_frames
def test_ltx2_is_legal():
assert is_legal_frames(201) and is_legal_frames(9) and is_legal_frames(25)
assert not is_legal_frames(200) and not is_legal_frames(8)
def test_ltx2_nearest():
assert nearest_legal_frames(200) == 201 # 200 -> nearest 8k+1
assert nearest_legal_frames(196) == 193
assert nearest_legal_frames(5) == 9 # floor at 9
def test_ltx2_duration_roundtrip():
assert duration_for_frames(201, 25) == 201 / 25
assert frames_for_duration(8.0, 25) == 201 # 200 -> 201
def test_ltx2_legal_series():
s = legal_frames(min_f=9, max_f=33)
assert s == [9, 17, 25, 33]
```
**Step 2 — run, expect ImportError/FAIL:** `LD_PRELOAD=/usr/lib/libstdc++.so.6 python -m pytest tests/test_utils.py -k ltx2 -q`
**Step 3 — implement `core/ltx2.py`:**
```python
"""LTX-2 frame-count math. Legal F satisfy F % 8 == 1 (8x temporal + 1)."""
def is_legal_frames(f: int) -> bool:
return f >= 9 and f % 8 == 1
def legal_frames(min_f: int = 9, max_f: int = 1000) -> list[int]:
start = max(9, min_f + ((1 - min_f) % 8)) # first 8k+1 >= min_f
return list(range(start, max_f + 1, 8))
def nearest_legal_frames(f: int) -> int:
if f <= 9:
return 9
low = ((f - 1) // 8) * 8 + 1
high = low + 8
return low if (f - low) <= (high - f) else high
def duration_for_frames(frames: int, fps: float) -> float:
return frames / fps
def frames_for_duration(duration: float, fps: float) -> int:
return nearest_legal_frames(round(duration * fps))
```
**Step 4 — run, expect PASS** (same command). **Step 5 — commit:** `feat: LTX-2 legal-frame helpers (core/ltx2.py)`.
---
## Stage 2 — ffmpeg pipeline params [TDD]
### Task 2.1: `target_fps`, `snap32`, `frames` in `build_ffmpeg_command`
**Files:** Modify `core/ffmpeg.py:74` (`build_ffmpeg_command`); Test `tests/test_utils.py`.
**Step 1 — failing tests:**
```python
def test_ffmpeg_ltx2_fps_and_frames():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
short_side=512, target_fps=25, frames=201)
assert "-r" in cmd and cmd[cmd.index("-r")+1] == "25"
assert "-frames:v" in cmd and cmd[cmd.index("-frames:v")+1] == "201"
vf = cmd[cmd.index("-vf")+1]
assert "fps=25" in vf
def test_ffmpeg_ltx2_snap32_crop():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
short_side=512, snap32=True)
vf = cmd[cmd.index("-vf")+1]
assert "crop=trunc(iw/32)*32:trunc(ih/32)*32" in vf
def test_ffmpeg_foley_unchanged():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4", short_side=256)
assert "-r" not in cmd and "-frames:v" not in cmd
assert "crop=trunc" not in cmd[cmd.index("-vf")+1]
```
**Step 2 — run, expect FAIL** (unexpected kwargs).
**Step 3 — implement:** add params `target_fps: float | None = None, snap32: bool = False, frames: int | None = None` to the signature. After the scale filter (and before the VAAPI block), append:
```python
if snap32:
filters.append("crop=trunc(iw/32)*32:trunc(ih/32)*32")
if target_fps is not None:
filters.append(f"fps={target_fps:g}")
```
Add output flags: after `-t duration` (or near the encoder args, before `output_path`), when `target_fps` set add `cmd += ["-r", f"{target_fps:g}"]`; when `frames` set add `cmd += ["-frames:v", str(frames)]` (video frame cap — exact F). Ensure ordering keeps `-vf` before outputs. Keep `fps`/`snap32` filters out of the `image_sequence=False` vs `True` branches consistently (they apply to both; webp seq also benefits from fps/÷32).
**Step 4 — run, expect PASS.** Also run full `tests/test_utils.py` (the 3 pre-existing failures only). **Step 5 — commit:** `feat: LTX-2 ffmpeg params (target_fps, snap32, frames)`.
### Task 2.2: audio extract honors frame-exact duration
**Files:** `core/ffmpeg.py:145` (`build_audio_extract_command`) — confirm it takes a duration; if it derives from a fixed 8 s, add a `duration` param so the `.wav` for an LTX-2 webp sequence is exactly `F/25 s`. Add a test mirroring `test_audio_extract_timing` asserting the `-t` value equals `frames/fps`. Commit: `fix: audio extract duration for LTX-2 frame-exact clips`.
---
## Stage 3 — per-tab `_mode`
### Task 3.1: attribute + persistence + migration
**Files:** `main.py``PlaylistWidget.__init__` (~3409, next to `_dest_folder`); `_save_playlist_tabs` (~5271); `_load_playlist_tabs` (~5315).
- Add `self._mode: str = "foley"` in `PlaylistWidget.__init__`.
- `_save_playlist_tabs`: add `"mode": pw._mode` to each tab dict.
- `_load_playlist_tabs`: after creating each pw, `pw._mode = t.get("mode", "foley")`.
- `_add_playlist_tab`: new tabs default `_mode="foley"` (already via init).
**Verify:** structure test passes; add `test_tab_mode_defaults_foley` (construct, assert each `_pws[i]._mode == "foley"`). Commit: `feat: per-tab export mode attribute (foley default)`.
---
## Stage 4 — tab menu: duplicate / convert / toggle
### Task 4.1: menu actions + label badge
**Files:** `main.py``_PlaylistTabBar.contextMenuEvent` (~3300) add items; new handlers in `MainWindow`; tab-title rendering.
- Add to the tab context menu: **"Duplicate tab"**, **"Duplicate as LTX-2"**, and a checkable **"LTX-2 mode"** (checked when `pw._mode=="ltx2"`). Emit new signals (e.g. `duplicate_requested(idx, as_ltx2: bool)`, `mode_toggle_requested(idx)`) like the existing `pin_toggle_requested`.
- `MainWindow._on_duplicate_tab(idx, as_ltx2)`: build a new tab via `_add_playlist_tab(label=…, files=list(src._paths), separators=sorted(src._separators_before), select=True)`; set `pw._dest_folder = src._dest_folder + ("_ltx2" if as_ltx2 else "")`; `pw._mode = "ltx2" if as_ltx2 else src._mode`; if ltx2, apply LTX-2 defaults (Stage 5 hook); `_save_playlist_tabs()`; refresh.
- `MainWindow._on_tab_mode_toggle(idx)`: flip `pw._mode`; if now ltx2, apply LTX-2 defaults; `_save_playlist_tabs()`; re-sync controls (Stage 5).
- Label badge: when adding/refreshing a tab whose `_mode=="ltx2"`, show `f"{label} [LTX2]"` (or set a distinct color) — apply in `_refresh_layout`/`_add_playlist_tab` title set.
**Verify:** manual launch — right-click a tab → Duplicate as LTX-2 creates a `[LTX2]` tab with `_ltx2` folder; toggle works. Structure test still green. Commit: `feat: tab duplicate / Duplicate-as-LTX-2 / mode toggle + [LTX2] badge`.
---
## Stage 5 — length control swap + export wiring
### Task 5.1: length control reflects active tab mode
**Files:** `main.py` — the clip-length widgets (`_spn_clip_dur` ~4051 area) + the tab-change sync hook (`_on_tab_changed` / `_sync_folder_field_to_tab` neighbor).
- Add a frames spinbox `_spn_frames` (min 9, singleStep 8 → always 8k+1; suffix " f"; tooltip live `= F/25 s`). Default 201.
- Add `_apply_mode_to_controls()`: if active tab `ltx2` → show `_spn_frames` (+ "Frames" label), hide the seconds Duration control, default resize 512 if unset; else show Duration (seconds), hide frames. Call it from `_on_tab_changed`, after `_on_duplicate_tab`/`_on_tab_mode_toggle`, and once after `_load_playlist_tabs`.
- A small label shows `= {F/25:.2f}s @25fps` updating on `_spn_frames.valueChanged`.
### Task 5.2: route LTX-2 params through export
**Files:** `main.py``_on_export` (~7317) + `ExportWorker` construction (~7484) + `_update_next_label`.
- When the active tab's `_mode=="ltx2"`: compute `frames = self._spn_frames.value()`; `fps = 25`; `duration = frames / fps`; pass `target_fps=25, snap32=True, frames=frames, duration=duration` through to `ExportWorker``build_ffmpeg_command`. Default `short_side` to 512 if 0/None in ltx2.
- Foley path: unchanged (no new params).
- `ExportWorker.__init__`/`run`: thread the new params (default None/False) into `build_ffmpeg_command`.
**Verify (manual, authoritative):** in an LTX-2 tab, export → inspect an output clip: `ffprobe` shows **25 fps, exactly F frames, W&H ÷32**; a Foley tab still exports 8 s/source-fps unchanged. Structure test green; full `pytest tests/test_utils.py` (3 pre-existing fails only). Commit: `feat: route LTX-2 (25fps, ÷32 crop, F frames) through export for ltx2 tabs`.
---
## Stage 6 — finalize
- **Task 6.1:** Full regression — `pytest tests/test_ui_structure.py` + `tests/test_utils.py` separately; manual: Foley export unchanged, LTX-2 export legal (ffprobe), duplicate/convert, persistence across relaunch, guardrail + per-tab folder still work.
- **Task 6.2:** Changelog (`main.py` CHANGELOG, bump APP_VERSION) + README note (per-tab LTX-2 mode). Commit `docs: changelog + README for LTX-2 export mode`.
- **Task 6.3:** Hand off branch (depends on `tab-export-folder`; merge that first, then this).
## Risks
| Risk | Mitigation |
|------|-----------|
| `-frames:v` vs `-t` interaction yields F±1 frames | Set both `-t F/fps` and `-frames:v F`; verify exact count with ffprobe in 5.2. |
| `fps` filter + HW (VAAPI) filter ordering | Place `fps`/`snap32` among CPU filters before the VAAPI hwupload block; test a HW-encoder build if available. |
| Length-control swap leaves stale state across tab switches | `_apply_mode_to_controls()` called on every tab change + mode toggle + load. |
| Depends on unmerged `tab-export-folder` | Branch is based on it; land that branch first. |
## NOT in scope
Hard enforcement (illegal F/resize allowed manually), motion-interpolated fps, auto re-export of existing Foley clips, DB schema changes, scan-pipeline changes.
+163 -10
View File
@@ -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:
@@ -3303,6 +3312,7 @@ class _PlaylistTabBar(QTabBar):
pin_toggle_requested = pyqtSignal(int)
tab_folder_toggle_requested = pyqtSignal(int)
duplicate_requested = pyqtSignal(int)
mode_toggle_requested = pyqtSignal(int)
def mouseDoubleClickEvent(self, event):
idx = self.tabAt(event.pos())
@@ -3329,6 +3339,9 @@ class _PlaylistTabBar(QTabBar):
act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False)))
act_rename = menu.addAction("Rename…")
act_dup = menu.addAction("Duplicate tab")
act_mode = menu.addAction("LTX-2 mode")
act_mode.setCheckable(True)
act_mode.setChecked(bool(getattr(pw, "_mode", "foley") == "ltx2"))
chosen = menu.exec(event.globalPos())
if chosen == act_pin:
self.pin_toggle_requested.emit(idx)
@@ -3338,6 +3351,8 @@ class _PlaylistTabBar(QTabBar):
self._start_edit(idx)
elif chosen == act_dup:
self.duplicate_requested.emit(idx)
elif chosen == act_mode:
self.mode_toggle_requested.emit(idx)
def _start_edit(self, idx: int) -> None:
editor = QLineEdit(self)
@@ -3411,6 +3426,7 @@ class PlaylistWidget(QListWidget):
self._pinned: bool = False # shown in the side-by-side view
self._tab_folder: bool = False # append this tab's name to export folder
self._dest_folder: str = "" # per-tab export destination
self._mode: str = "foley" # export pipeline mode: "foley" | "ltx2"
self._label: str = "" # tab name (source of truth across views)
self._visible: list[str | None] = [] # rows shown; None = separator row
self._selected_path: str | None = None
@@ -3951,6 +3967,7 @@ class MainWindow(QMainWindow):
self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect(
self._on_tab_folder_toggle)
self._playlist_tabs.tabBar().duplicate_requested.connect(self._on_duplicate_tab)
self._playlist_tabs.tabBar().mode_toggle_requested.connect(self._on_tab_mode_toggle)
self._playlist_tabs.tabCloseRequested.connect(self._on_close_tab)
self._playlist_tabs.currentChanged.connect(self._on_tab_changed)
self._btn_add_tab = QPushButton("+")
@@ -4132,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")
@@ -4536,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)
@@ -4625,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)
@@ -4761,9 +4799,25 @@ class MainWindow(QMainWindow):
# ── Changelog ────────────────────────────────────────────
APP_VERSION = "1.1"
APP_VERSION = "1.2"
_SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync)
CHANGELOG: list[tuple[str, list[str]]] = [
("1.2", [
"<b>Per-tab export folder</b> — each file-list tab now remembers "
"its own output folder; switching tabs follows that tab's folder. "
"An <b>export-folder mismatch guardrail</b> warns when the loaded "
"video's talent/folder doesn't match the destination, so clips "
"don't land in the wrong tree.",
"<b>Duplicate tab</b> — right-click a file-list tab → "
"<i>Duplicate tab</i> to clone its files into a new tab with its "
"own export folder.",
"<b>LTX-2 export mode</b> — a per-tab <b>Foley | LTX-2</b> toggle "
"(right-click a tab) marked with an <code>[LTX2]</code> badge. "
"LTX-2 clips are frame-exact (<code>frames % 8 == 1</code>, set via "
"the frames control), forced to <b>25 fps</b>, and center-cropped so "
"width &amp; height are divisible by 32 — for LTX-2 video-to-audio "
"datasets. Applies to manual, re-export, and auto-export.",
]),
("1.1", [
"<b>Reorganized interface</b> — the dense control rows are now a "
"<b>menu bar</b> (File / Edit / Scan / View / Help) for occasional "
@@ -5007,6 +5061,61 @@ 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:
return
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:
"""Displayed tab title — appends a [LTX2] badge for ltx2-mode tabs.
Does NOT mutate pw._label (the source of truth for export folders)."""
return f"{pw._label} [LTX2]" if getattr(pw, "_mode", "foley") == "ltx2" else pw._label
# ── File-list tabs ───────────────────────────────────────────
def _wire_pw(self, pw: "PlaylistWidget") -> None:
pw.file_selected.connect(self._load_file)
@@ -5068,7 +5177,7 @@ class MainWindow(QMainWindow):
for pw in self._pws:
if not pw._pinned:
pw.setMinimumWidth(0)
self._playlist_tabs.addTab(pw, pw._label)
self._playlist_tabs.addTab(pw, self._tab_title(pw))
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setChildrenCollapsible(False)
for pw in pinned:
@@ -5079,7 +5188,7 @@ class MainWindow(QMainWindow):
header = QWidget()
hdr = QHBoxLayout(header)
hdr.setContentsMargins(2, 1, 2, 1)
lbl = QLabel(pw._label)
lbl = QLabel(self._tab_title(pw))
lbl.setStyleSheet("font-weight: bold;")
btn = QPushButton("")
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
@@ -5104,7 +5213,7 @@ class MainWindow(QMainWindow):
else:
for pw in self._pws:
pw.setMinimumWidth(200)
self._playlist_tabs.addTab(pw, pw._label)
self._playlist_tabs.addTab(pw, self._tab_title(pw))
self._list_stack.setCurrentWidget(self._playlist_tabs)
self._set_left_pane_width(220)
finally:
@@ -5265,6 +5374,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:
@@ -5302,6 +5412,7 @@ class MainWindow(QMainWindow):
"pinned": pw._pinned,
"tab_folder": pw._tab_folder,
"export_folder": pw._dest_folder,
"mode": pw._mode,
} for pw in self._pws]
cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0
data = {"tabs": tabs, "current": cur}
@@ -5347,6 +5458,7 @@ class MainWindow(QMainWindow):
pw._tab_folder = bool(t.get("tab_folder"))
pw._dest_folder = t.get("export_folder") or self._settings.value(
"export_folder", str(Path.home()))
pw._mode = t.get("mode", "foley")
cur = min(max(0, data.get("current", 0)), len(self._pws) - 1)
finally:
self._loading_tabs = False
@@ -7192,6 +7304,16 @@ class MainWindow(QMainWindow):
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
is_scan = getattr(self, '_auto_export_no_markers', False)
clip_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. Captured at
# batch-build time so queued batches keep their own geometry.
ltx2 = self._ltx2_export_params()
if ltx2 is not None:
short_side = ltx2["short_side"]
clip_duration = ltx2["duration"]
batch = {
"jobs": jobs,
"positions": positions,
@@ -7200,13 +7322,16 @@ class MainWindow(QMainWindow):
"image_sequence": image_sequence,
"max_workers": max_workers,
"encoder": encoder,
"clip_duration": self._clip_dur,
"clip_duration": clip_duration,
"spread": spread,
"folder": folder,
"format": fmt,
"profile": self._profile,
"is_scan": is_scan,
"replace_scan_exports": replace_scan_exports,
"target_fps": ltx2["target_fps"] if ltx2 else None,
"snap32": ltx2["snap32"] if ltx2 else False,
"frames": ltx2["frames"] if ltx2 else None,
}
if self._export_worker and self._export_worker.isRunning():
@@ -7252,6 +7377,9 @@ class MainWindow(QMainWindow):
max_workers=batch["max_workers"],
encoder=batch["encoder"],
duration=batch["clip_duration"],
target_fps=batch.get("target_fps"),
snap32=batch.get("snap32", False),
frames=batch.get("frames"),
)
self._export_worker.finished.connect(self._on_auto_clip_done)
self._export_worker.all_done.connect(self._on_auto_batch_done)
@@ -7545,6 +7673,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.
@@ -7554,7 +7691,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
@@ -7577,14 +7714,18 @@ class MainWindow(QMainWindow):
# (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()
_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)
@@ -7812,6 +7953,15 @@ class MainWindow(QMainWindow):
encoder = self._hw_encoders[0] if hw_on else "libx264"
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
clip_dur = 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"]
clip_dur = ltx2["duration"]
self._export_spread = spread
self._export_clip_duration = clip_dur
self._export_folder = folder
@@ -7829,6 +7979,9 @@ class MainWindow(QMainWindow):
max_workers=max_workers,
encoder=encoder,
duration=clip_dur,
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_reexport_clip_done)
self._export_worker.all_done.connect(self._on_reexport_batch_done)
+53
View File
@@ -137,3 +137,56 @@ def test_duplicate_tab(win):
assert dup._label == "AlexisCrystal copy"
# sibling, not a child: ".../alexis/" -> ".../alexis_copy" (not ".../alexis/_copy")
assert dup._dest_folder == "/data/alexis_copy"
def test_tab_mode_defaults_foley(win):
# Fresh tabs use the Foley pipeline; sessions/tabs without a stored mode
# load unchanged.
assert win._pws
for pw in win._pws:
assert pw._mode == "foley"
def test_tab_mode_toggle(win):
# Right-click → "LTX-2 mode" flips the per-tab mode and the displayed title
# gains a [LTX2] badge (without mutating pw._label). Suppress QSettings
# writes via _loading_tabs so the test can't touch the real session.
win._loading_tabs = True
try:
win._on_tab_mode_toggle(win._playlist_tabs.indexOf(win._pws[0]))
finally:
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()
+54
View File
@@ -439,3 +439,57 @@ def test_apply_keyframes_before_first_uses_base():
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio="4:5",
base_rand_p=True, base_rand_s=False)
assert result == [(1.0, "/out/a", "4:5", 0.5, True, False)]
# --- LTX-2 legal-frame math (core/ltx2.py) ---
from core.ltx2 import is_legal_frames, nearest_legal_frames, frames_for_duration, duration_for_frames, legal_frames
def test_ltx2_is_legal():
assert is_legal_frames(201) and is_legal_frames(9) and is_legal_frames(25)
assert not is_legal_frames(200) and not is_legal_frames(8)
def test_ltx2_nearest():
assert nearest_legal_frames(200) == 201 # 200 -> nearest 8k+1
assert nearest_legal_frames(196) == 193
assert nearest_legal_frames(5) == 9 # floor at 9
def test_ltx2_duration_roundtrip():
assert duration_for_frames(201, 25) == 201 / 25
assert frames_for_duration(8.0, 25) == 201 # 200 -> 201
def test_ltx2_legal_series():
s = legal_frames(min_f=9, max_f=33)
assert s == [9, 17, 25, 33]
# --- LTX-2 ffmpeg params (target_fps, snap32, frames) ---
def test_ffmpeg_ltx2_fps_and_frames():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
short_side=512, target_fps=25, frames=201)
assert "-r" in cmd and cmd[cmd.index("-r")+1] == "25"
assert "-frames:v" in cmd and cmd[cmd.index("-frames:v")+1] == "201"
vf = cmd[cmd.index("-vf")+1]
assert "fps=25" in vf
def test_ffmpeg_ltx2_snap32_crop():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
short_side=512, snap32=True)
vf = cmd[cmd.index("-vf")+1]
assert "crop=trunc(iw/32)*32:trunc(ih/32)*32" in vf
def test_ffmpeg_foley_unchanged():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4", short_side=256)
assert "-r" not in cmd and "-frames:v" not in cmd
assert "crop=trunc" not in cmd[cmd.index("-vf")+1]
# --- LTX-2 audio extract frame-exact duration ---
def test_audio_extract_ltx2_duration():
frames, fps = 201, 25
cmd = build_audio_extract_command("/in/v.mp4", 0.0, "/out/clip_001",
duration=frames / fps)
assert "-t" in cmd
assert cmd[cmd.index("-t") + 1] == str(frames / fps)