20 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
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
Ethanfel 1eb7de2a1a fix: duplicate-tab folder is a sibling, not a child, when source ends in /
".../AlexisCrystal/" + "_copy" was producing ".../AlexisCrystal/_copy"; rstrip
the trailing separator first → ".../AlexisCrystal_copy". Regression test uses a
trailing-slash source folder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:52:12 +02:00
Ethanfel d7680283a2 test: isolate QSettings in GUI tests so they never touch the real ~/.config/8cut
Constructing MainWindow loads and (on close) re-saves the playlist tabs; a test
that mutated tab state could persist into the user's real session. Redirect
QSettings to a temp dir at import time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:47:35 +02:00
Ethanfel bf4b6dad2d feat: right-click "Duplicate tab" — clone files into a new tab with adapted name + own folder
New tab copies the source tab's video list + separators, gets a unique
"<name> copy" label and an adapted own export folder ("<folder>_copy"), and
inherits the tab-named-folder flag. No files are moved or copied — you export
into the new tab's folder. Keeps Foley/variant datasets separate without the
file-shuffling that a misexport used to require.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:36:47 +02:00
Ethanfel 4715c0ce49 fix: sync export folder when selecting a file in a side-by-side list; tighten guardrail; rename per-tab attr
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 01:06:21 +02:00
Ethanfel e5ce59c065 feat: bind export folder to each file-list tab + export-folder mismatch guardrail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 00:56:55 +02:00
8 changed files with 1025 additions and 34 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 - **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) - **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 - **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 - **Status bar** — export/scan progress and messages, with the current file · profile · worker count always shown
## Keyboard shortcuts ## Keyboard shortcuts
+61
View File
@@ -79,6 +79,9 @@ def build_ffmpeg_command(
image_sequence: bool = False, image_sequence: bool = False,
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,
) -> list[str]: ) -> list[str]:
# -ss before -i: fast input-seeking. Safe here because we always re-encode, # -ss before -i: fast input-seeking. Safe here because we always re-encode,
# so there is no keyframe-alignment issue from pre-input seek. # 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" 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. # VAAPI: decoded frames are GPU surfaces. CPU filters need hwdownload first.
if use_hw_vaapi: if use_hw_vaapi:
if filters: if filters:
@@ -120,6 +130,12 @@ def build_ffmpeg_command(
if filters: if filters:
cmd += ["-vf", ",".join(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: if image_sequence:
cmd += [ cmd += [
"-an", "-an",
@@ -157,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.
+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.
+450 -34
View File
@@ -5,6 +5,7 @@ locale.setlocale(locale.LC_NUMERIC, "C") # required by libmpv before any import
import sys import sys
import os import os
import random import random
import re
import shutil import shutil
import subprocess import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -34,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"
@@ -47,6 +50,11 @@ def _icon(name: str) -> "QIcon":
return QIcon(str(_ASSET_DIR / "icons" / name)) return QIcon(str(_ASSET_DIR / "icons" / name))
def _norm_token(s: str) -> str:
"""Lowercase a string and strip everything but [a-z0-9] for fuzzy matching."""
return re.sub(r"[^a-z0-9]", "", s.lower())
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"] _SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
@@ -101,7 +109,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), ...]
@@ -110,6 +121,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()
@@ -138,6 +152,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:
@@ -1880,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
@@ -2042,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.
@@ -2270,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):
@@ -3296,6 +3339,8 @@ class _PlaylistTabBar(QTabBar):
tab_renamed = pyqtSignal(int, str) tab_renamed = pyqtSignal(int, str)
pin_toggle_requested = pyqtSignal(int) pin_toggle_requested = pyqtSignal(int)
tab_folder_toggle_requested = pyqtSignal(int) tab_folder_toggle_requested = pyqtSignal(int)
duplicate_requested = pyqtSignal(int)
mode_toggle_requested = pyqtSignal(int)
def mouseDoubleClickEvent(self, event): def mouseDoubleClickEvent(self, event):
idx = self.tabAt(event.pos()) idx = self.tabAt(event.pos())
@@ -3321,6 +3366,10 @@ class _PlaylistTabBar(QTabBar):
act_tabfolder.setCheckable(True) act_tabfolder.setCheckable(True)
act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False))) act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False)))
act_rename = menu.addAction("Rename…") 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()) chosen = menu.exec(event.globalPos())
if chosen == act_pin: if chosen == act_pin:
self.pin_toggle_requested.emit(idx) self.pin_toggle_requested.emit(idx)
@@ -3328,6 +3377,10 @@ class _PlaylistTabBar(QTabBar):
self.tab_folder_toggle_requested.emit(idx) self.tab_folder_toggle_requested.emit(idx)
elif chosen == act_rename: elif chosen == act_rename:
self._start_edit(idx) 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: def _start_edit(self, idx: int) -> None:
editor = QLineEdit(self) editor = QLineEdit(self)
@@ -3400,6 +3453,8 @@ class PlaylistWidget(QListWidget):
self._missing: set[str] = set() # paths not present on disk self._missing: set[str] = set() # paths not present on disk
self._pinned: bool = False # shown in the side-by-side view 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._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._label: str = "" # tab name (source of truth across views)
self._visible: list[str | None] = [] # rows shown; None = separator row self._visible: list[str | None] = [] # rows shown; None = separator row
self._selected_path: str | None = None self._selected_path: str | None = None
@@ -3922,6 +3977,9 @@ class MainWindow(QMainWindow):
self._playlist_filter.setClearButtonEnabled(True) self._playlist_filter.setClearButtonEnabled(True)
self._playlist_filter.textChanged.connect(self._on_filter_changed) self._playlist_filter.textChanged.connect(self._on_filter_changed)
# Guard against the textChanged→tab-save loop when we programmatically
# sync _txt_folder to the active tab's stored export folder.
self._syncing_folder = False
# Suppress tab persistence until _load_playlist_tabs runs at the end of # Suppress tab persistence until _load_playlist_tabs runs at the end of
# __init__ (the profile combo it needs doesn't exist yet). # __init__ (the profile combo it needs doesn't exist yet).
self._loading_tabs = True self._loading_tabs = True
@@ -3936,6 +3994,8 @@ class MainWindow(QMainWindow):
self._playlist_tabs.tabBar().pin_toggle_requested.connect(self._on_pin_toggle) self._playlist_tabs.tabBar().pin_toggle_requested.connect(self._on_pin_toggle)
self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect( self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect(
self._on_tab_folder_toggle) 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.tabCloseRequested.connect(self._on_close_tab)
self._playlist_tabs.currentChanged.connect(self._on_tab_changed) self._playlist_tabs.currentChanged.connect(self._on_tab_changed)
self._btn_add_tab = QPushButton("+") self._btn_add_tab = QPushButton("+")
@@ -4046,6 +4106,7 @@ class MainWindow(QMainWindow):
self._txt_folder.textChanged.connect( self._txt_folder.textChanged.connect(
lambda v: self._settings.setValue("export_folder", v) lambda v: self._settings.setValue("export_folder", v)
) )
self._txt_folder.textChanged.connect(self._on_export_folder_edited)
self._btn_folder = QPushButton("...") self._btn_folder = QPushButton("...")
self._btn_folder.setFixedWidth(30) self._btn_folder.setFixedWidth(30)
self._btn_folder.setToolTip("Browse for output folder") self._btn_folder.setToolTip("Browse for output folder")
@@ -4116,6 +4177,20 @@ 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._spn_frames.editingFinished.connect(self._snap_frames_to_legal)
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")
@@ -4357,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
@@ -4383,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()
@@ -4520,6 +4629,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)
@@ -4609,10 +4719,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)
@@ -4745,9 +4862,25 @@ class MainWindow(QMainWindow):
# ── Changelog ──────────────────────────────────────────── # ── Changelog ────────────────────────────────────────────
APP_VERSION = "1.1" APP_VERSION = "1.2"
_SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync) _SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync)
CHANGELOG: list[tuple[str, list[str]]] = [ 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", [ ("1.1", [
"<b>Reorganized interface</b> — the dense control rows are now a " "<b>Reorganized interface</b> — the dense control rows are now a "
"<b>menu bar</b> (File / Edit / Scan / View / Help) for occasional " "<b>menu bar</b> (File / Edit / Scan / View / Help) for occasional "
@@ -4919,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()
@@ -4930,6 +5066,28 @@ class MainWindow(QMainWindow):
def _export_base_name(self) -> str: def _export_base_name(self) -> str:
return os.path.basename(self._tab_export_folder()) return os.path.basename(self._tab_export_folder())
def _on_export_folder_edited(self, text: str) -> None:
"""User edited the folder field → store it on the active tab."""
if self._syncing_folder:
return
pw = self._playlist
if pw is not None:
pw._dest_folder = text
self._save_playlist_tabs()
def _sync_folder_field_to_tab(self) -> None:
"""Reflect the active tab's stored export folder in the folder field."""
pw = self._playlist
if pw is None:
return
folder = getattr(pw, "_dest_folder", "") or self._settings.value(
"export_folder", str(Path.home()))
if folder != self._txt_folder.text():
self._syncing_folder = True
self._txt_folder.setText(folder)
self._syncing_folder = False
self._update_next_label()
def _on_tab_folder_toggle(self, idx: int) -> None: def _on_tab_folder_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:
@@ -4941,6 +5099,107 @@ class MainWindow(QMainWindow):
self._refresh_playlist_checks() self._refresh_playlist_checks()
self._update_next_label() self._update_next_label()
def _on_duplicate_tab(self, idx: int) -> None:
"""Clone a tab's file list into a new tab with an adapted name and its
own (adapted) export folder. No files are moved or copied the new tab
just targets a separate dataset folder you export into."""
src = self._playlist_tabs.widget(idx)
if src is None:
return
base = f"{src._label} copy"
label, n = base, 2
existing = {pw._label for pw in self._pws}
while label in existing:
label = f"{base} {n}"
n += 1
pw = self._add_playlist_tab(
label=label,
files=list(src._paths),
separators=sorted(src._separators_before),
select=True,
)
src_folder = getattr(src, "_dest_folder", "")
# rstrip the trailing separator so ".../AlexisCrystal/" + "_copy" becomes
# a sibling ".../AlexisCrystal_copy", not a child ".../AlexisCrystal/_copy".
pw._dest_folder = (src_folder.rstrip("/" + os.sep) + "_copy") if src_folder else ""
pw._tab_folder = getattr(src, "_tab_folder", False)
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._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 _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:
"""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 ─────────────────────────────────────────── # ── File-list tabs ───────────────────────────────────────────
def _wire_pw(self, pw: "PlaylistWidget") -> None: def _wire_pw(self, pw: "PlaylistWidget") -> None:
pw.file_selected.connect(self._load_file) pw.file_selected.connect(self._load_file)
@@ -4958,6 +5217,10 @@ class MainWindow(QMainWindow):
select: bool = True) -> "PlaylistWidget": select: bool = True) -> "PlaylistWidget":
pw = PlaylistWidget() pw = PlaylistWidget()
self._wire_pw(pw) self._wire_pw(pw)
# Inherit the current folder field (overwritten on load). _txt_folder may
# not exist yet during the bootstrap tab built before widgets are wired.
_fld = getattr(self, "_txt_folder", None)
pw._dest_folder = _fld.text() if _fld is not None else ""
pw._label = label or f"List {len(self._pws) + 1}" pw._label = label or f"List {len(self._pws) + 1}"
self._pws.append(pw) self._pws.append(pw)
if separators: if separators:
@@ -4998,7 +5261,7 @@ class MainWindow(QMainWindow):
for pw in self._pws: for pw in self._pws:
if not pw._pinned: if not pw._pinned:
pw.setMinimumWidth(0) 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 = QSplitter(Qt.Orientation.Horizontal)
splitter.setChildrenCollapsible(False) splitter.setChildrenCollapsible(False)
for pw in pinned: for pw in pinned:
@@ -5009,7 +5272,7 @@ class MainWindow(QMainWindow):
header = QWidget() header = QWidget()
hdr = QHBoxLayout(header) hdr = QHBoxLayout(header)
hdr.setContentsMargins(2, 1, 2, 1) hdr.setContentsMargins(2, 1, 2, 1)
lbl = QLabel(pw._label) lbl = QLabel(self._tab_title(pw))
lbl.setStyleSheet("font-weight: bold;") lbl.setStyleSheet("font-weight: bold;")
btn = QPushButton("") btn = QPushButton("")
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
@@ -5034,7 +5297,7 @@ class MainWindow(QMainWindow):
else: else:
for pw in self._pws: for pw in self._pws:
pw.setMinimumWidth(200) 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._list_stack.setCurrentWidget(self._playlist_tabs)
self._set_left_pane_width(220) self._set_left_pane_width(220)
finally: finally:
@@ -5193,6 +5456,7 @@ 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._on_active_pw_changed()
self._apply_playlist_filters() self._apply_playlist_filters()
self._save_playlist_tabs() self._save_playlist_tabs()
@@ -5230,6 +5494,8 @@ class MainWindow(QMainWindow):
"separators": sorted(pw._separators_before), "separators": sorted(pw._separators_before),
"pinned": pw._pinned, "pinned": pw._pinned,
"tab_folder": pw._tab_folder, "tab_folder": pw._tab_folder,
"export_folder": pw._dest_folder,
"mode": pw._mode,
} for pw in self._pws] } for pw in self._pws]
cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0 cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0
data = {"tabs": tabs, "current": cur} data = {"tabs": tabs, "current": cur}
@@ -5273,6 +5539,9 @@ class MainWindow(QMainWindow):
separators=t.get("separators", []), select=False) separators=t.get("separators", []), select=False)
pw._pinned = bool(t.get("pinned")) pw._pinned = bool(t.get("pinned"))
pw._tab_folder = bool(t.get("tab_folder")) 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) cur = min(max(0, data.get("current", 0)), len(self._pws) - 1)
finally: finally:
self._loading_tabs = False self._loading_tabs = False
@@ -5282,6 +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._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)
@@ -5335,6 +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._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)
@@ -5371,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}")
@@ -5389,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
@@ -5499,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()
@@ -5508,7 +5780,10 @@ class MainWindow(QMainWindow):
return # ignore auto-selection while rebuilding tabs return # ignore auto-selection while rebuilding tabs
# The list that emitted this becomes the active pane (side-by-side). # The list that emitted this becomes the active pane (side-by-side).
sender = self.sender() sender = self.sender()
if isinstance(sender, PlaylistWidget) and sender in self._pws: if isinstance(sender, PlaylistWidget) and sender in self._pws and sender is not self._active_pw:
self._active_pw = sender
self._on_active_pw_changed()
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):
self._show_status(f"File not found: {os.path.basename(path)}", 5000) self._show_status(f"File not found: {os.path.basename(path)}", 5000)
@@ -5568,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 = ""
@@ -5661,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})
@@ -5908,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)
@@ -5918,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:
@@ -6100,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)
@@ -6109,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
@@ -6333,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 …
@@ -6408,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"))
@@ -6481,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()
@@ -7061,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,
@@ -7113,6 +7469,16 @@ class MainWindow(QMainWindow):
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()
is_scan = getattr(self, '_auto_export_no_markers', False) 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 (see `ltx2` above) and keep byte-identical behavior.
# `ltx2` was captured at the top of this batch build so the windowing
# min_dur and the stashed geometry share one consistent length.
if ltx2 is not None:
short_side = ltx2["short_side"]
clip_duration = ltx2["duration"]
batch = { batch = {
"jobs": jobs, "jobs": jobs,
"positions": positions, "positions": positions,
@@ -7121,13 +7487,16 @@ class MainWindow(QMainWindow):
"image_sequence": image_sequence, "image_sequence": image_sequence,
"max_workers": max_workers, "max_workers": max_workers,
"encoder": encoder, "encoder": encoder,
"clip_duration": self._clip_dur, "clip_duration": clip_duration,
"spread": spread, "spread": spread,
"folder": folder, "folder": folder,
"format": fmt, "format": fmt,
"profile": self._profile, "profile": self._profile,
"is_scan": is_scan, "is_scan": is_scan,
"replace_scan_exports": replace_scan_exports, "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(): if self._export_worker and self._export_worker.isRunning():
@@ -7173,6 +7542,9 @@ class MainWindow(QMainWindow):
max_workers=batch["max_workers"], max_workers=batch["max_workers"],
encoder=batch["encoder"], encoder=batch["encoder"],
duration=batch["clip_duration"], 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.finished.connect(self._on_auto_clip_done)
self._export_worker.all_done.connect(self._on_auto_batch_done) self._export_worker.all_done.connect(self._on_auto_batch_done)
@@ -7337,6 +7709,24 @@ class MainWindow(QMainWindow):
folder = self._tab_export_folder() folder = self._tab_export_folder()
if folder_suffix: if folder_suffix:
folder = folder.rstrip(os.sep) + "_" + folder_suffix folder = folder.rstrip(os.sep) + "_" + folder_suffix
# Guardrail: warn if the loaded video's parent folder name doesn't
# appear anywhere in the destination — likely a mismatched tab/folder.
vid_parent = os.path.basename(os.path.dirname(self._file_path))
vid_tok = _norm_token(vid_parent)
folder_tokens = [_norm_token(p) for p in folder.split(os.sep) if p]
if len(vid_tok) >= 3 and not any(vid_tok in ft for ft in folder_tokens):
resp = QMessageBox.question(
self, "Export folder mismatch",
f"The loaded video is under:\n {vid_parent}\n\n"
f"but you're exporting to:\n {folder}\n\nExport anyway?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if resp != QMessageBox.StandardButton.Yes:
self._show_status("Export cancelled (folder mismatch)", 4000)
return
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
spread = self._spn_spread.value() spread = self._spn_spread.value()
@@ -7448,6 +7838,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.
@@ -7457,7 +7856,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
@@ -7480,14 +7879,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)
@@ -7715,6 +8118,15 @@ class MainWindow(QMainWindow):
encoder = self._hw_encoders[0] if hw_on else "libx264" 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() max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
clip_dur = self._clip_dur 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_spread = spread
self._export_clip_duration = clip_dur self._export_clip_duration = clip_dur
self._export_folder = folder self._export_folder = folder
@@ -7732,6 +8144,9 @@ class MainWindow(QMainWindow):
max_workers=max_workers, max_workers=max_workers,
encoder=encoder, encoder=encoder,
duration=clip_dur, 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.finished.connect(self._on_reexport_clip_done)
self._export_worker.all_done.connect(self._on_reexport_batch_done) self._export_worker.all_done.connect(self._on_reexport_batch_done)
@@ -7847,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()
+164
View File
@@ -1,5 +1,15 @@
import pytest import pytest
# Redirect QSettings to a throwaway dir BEFORE any MainWindow is constructed, so
# these GUI tests can never read or clobber the user's real ~/.config/8cut.conf
# (constructing MainWindow loads — and on window close re-saves — the playlist
# tabs; a test mutating tab state would otherwise persist into the real session).
import tempfile as _tempfile
from PyQt6.QtCore import QSettings as _QSettings
_QS_DIR = _tempfile.mkdtemp(prefix="8cut-test-qs-")
_QSettings.setPath(_QSettings.Format.NativeFormat, _QSettings.Scope.UserScope, _QS_DIR)
_QSettings.setPath(_QSettings.Format.IniFormat, _QSettings.Scope.UserScope, _QS_DIR)
# A real platform is needed because MpvWidget creates a GL context. # A real platform is needed because MpvWidget creates a GL context.
# If construction fails for any environment reason, skip — this test is a # If construction fails for any environment reason, skip — this test is a
# best-effort structural net, not a gate on core/ tests. # best-effort structural net, not a gate on core/ tests.
@@ -107,3 +117,157 @@ def test_side_by_side_menu_pins_third_panel(win):
win._deck_loading = False win._deck_loading = False
assert win._tab_crop._pinned is True assert win._tab_crop._pinned is True
assert len(_split_columns(win)) == 3 assert len(_split_columns(win)) == 3
def test_duplicate_tab(win):
# Right-click → Duplicate tab: clones files into a new tab with an adapted
# name + adapted own folder, no file moves. Suppress QSettings writes via
# _loading_tabs so the test can't touch the real session.
win._loading_tabs = True
try:
src = win._pws[0]
src._label = "AlexisCrystal"
src._dest_folder = "/data/alexis/" # trailing slash, like real folders
n_before = len(win._pws)
win._on_duplicate_tab(win._playlist_tabs.indexOf(src))
finally:
win._loading_tabs = False
assert len(win._pws) == n_before + 1
dup = win._pws[-1]
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()
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
+76
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():
@@ -439,3 +461,57 @@ def test_apply_keyframes_before_first_uses_base():
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio="4:5", result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio="4:5",
base_rand_p=True, base_rand_s=False) base_rand_p=True, base_rand_s=False)
assert result == [(1.0, "/out/a", "4:5", 0.5, True, 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)