Compare commits
20 Commits
cbbdfeadb1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| eab5c690c7 | |||
| 4445f0e7f4 | |||
| ed63d04abf | |||
| 7ae1720b9e | |||
| 514607eddd | |||
| 4299de5f97 | |||
| 86ab606059 | |||
| 87ccd8650c | |||
| ad9e564991 | |||
| 4baac54930 | |||
| 879684ce25 | |||
| 92774216d4 | |||
| 02fd0f0919 | |||
| c537ac678d | |||
| 755f7e5131 | |||
| 1eb7de2a1a | |||
| d7680283a2 | |||
| bf4b6dad2d | |||
| 4715c0ce49 | |||
| e5ce59c065 |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)·((F−1)/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 (384–512) 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.
|
||||||
@@ -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 & 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 3–5 on consumer NVIDIA cards), so cap workers.
|
# (typically 3–5 on consumer NVIDIA cards), so cap workers.
|
||||||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user