Author SHA1 Message Date
Ethanfel b52b3e19be feat: improve audio region timeline picking 2026-07-04 20:16:08 +02:00
Ethanfel 678f6e5cc4 feat: add heal cut ffmpeg command 2026-07-04 20:14:19 +02:00
Ethanfel fec08cfcb3 docs: plan audio heal cut implementation 2026-07-04 20:13:21 +02:00
Ethanfel 7ff870ee88 docs: design audio timeline picker and heal cut 2026-07-04 19:42:17 +02:00
Ethanfel 158483ce42 feat: '+ To library' — add the current audio area straight to the clip library 2026-07-04 01:11:05 +02:00
EthanfelandClaude Opus 4.8 0e82a82cde feat: auto-add extracted/edited clips to the library + library Edit opens the editor (v1.7)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:54:43 +02:00
Ethanfel f32d303d26 feat: persistent Library tab in ScanResultsPanel (survives per-file rebuilds) 2026-07-04 00:20:23 +02:00
Ethanfel 5dc7499d7f feat: AudioLibraryTab — persistent extracted-clip library (add/edit/re-export/play/remove) 2026-07-04 00:06:10 +02:00
Ethanfel 7f414d80b8 feat: Audio tab drives timeline audio-mode; drag the band to set the extract region 2026-07-03 02:23:30 +02:00
Ethanfel a993de615c feat: timeline audio-mode — hide clip span, draggable/resizable audio band 2026-07-03 02:10:04 +02:00
EthanfelandClaude Opus 4.8 283bc65588 fix: escape ampersand in 'Extract && Edit' QToolBox title
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:57:43 +02:00
EthanfelandClaude Opus 4.8 d8b890d679 fix: editor cleanup on Escape/reject + stop main audition on open
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:41:41 +02:00
EthanfelandClaude Opus 4.8 4a34a87d37 feat: editor playback + temp cleanup on close + docs (v1.6)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:31:13 +02:00
Ethanfel 4d54f29514 feat: wire editor ops (delete/silence/reverse/trim) + undo/redo + save-as 2026-07-02 18:22:39 +02:00
EthanfelandClaude Opus 4.8 a11e4859dd feat: AudioEditorDialog scaffold + Edit-clip entry point (ops stubbed)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:09:54 +02:00
Ethanfel 1553d7faf0 feat: destructive audio op command builders (delete/silence/reverse region) 2026-07-02 17:54:11 +02:00
Ethanfel 68368b4ff6 feat: double-click a merge clip to set its join crossfade/curve override + row labels 2026-07-02 17:50:40 +02:00
EthanfelandClaude Opus 4.8 65200e482e feat: global crossfade curve combo + per-join render/guard plumbing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:40:01 +02:00
Ethanfel a298418156 feat: per-join crossfade durations + acrossfade curve in build_crossfade_merge_command 2026-07-02 17:35:26 +02:00
EthanfelandClaude Opus 4.8 449b3dfa72 feat: waveform playhead during audition + docs (v1.5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:27:37 +02:00
Ethanfel 356cfcc7d7 feat: wire interactive waveform selection to cursor/length (two-way, clamped view) 2026-07-02 17:18:26 +02:00
Ethanfel 25fbf8142f feat: waveform drag-select + wheel zoom (selection_changed/view_changed) 2026-07-02 17:06:46 +02:00
Ethanfel 7ca894d585 feat: waveform view/selection state + band/handle painting 2026-07-02 16:56:36 +02:00
Ethanfel ee7de4d83c feat: pixel<->time geometry helpers for the waveform 2026-07-02 16:54:00 +02:00
EthanfelandClaude Opus 4.8 86980b38cf docs: Phase 3a implementation plan — interactive waveform selection
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:52:29 +02:00
EthanfelandClaude Opus 4.8 799b064703 docs: Phase 3 design — interactive waveform + per-join crossfade/curves + destructive editor
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:51:29 +02:00
Ethanfel 43326730f2 polish: preview failure dialog + clean audition temp on close + drop redundant Qt imports 2026-07-02 16:35:53 +02:00
EthanfelandClaude Opus 4.8 14ec29dbbe feat: merge temp cleanup on close + v1.4 changelog + README (Phase 2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:21:10 +02:00
Ethanfel 3922fc5174 feat: merge save + preview (crossfade render, duration guard, ffplay preview) 2026-07-02 16:18:29 +02:00
Ethanfel b8aa1606d8 feat: Merge sequence — add (file/selection), reorder, remove 2026-07-02 16:04:01 +02:00
EthanfelandClaude Opus 4.8 de7da3cad6 feat: Merge pane (3rd Audio QToolBox page) — list + crossfade + buttons
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:54:05 +02:00
Ethanfel 52417ea1ee feat: build_crossfade_merge_command — chained acrossfade / concat 2026-07-02 15:45:33 +02:00
EthanfelandClaude Opus 4.8 7573277a03 docs: audio merge (crossfade) — Phase 2 design + implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:37:31 +02:00
Ethanfel 578ad87b1a docs: Audio workspace tab (Phase 1) — v1.3 changelog + README; drop orphaned transport spacer 2026-07-02 15:18:38 +02:00
EthanfelandClaude Opus 4.8 c57c078100 feat: play/stop audition of the current audio region
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:13:06 +02:00
Ethanfel 7501c4729b refactor: cap waveform decode length + guard frombuffer + tighten test 2026-07-02 15:00:34 +02:00
Ethanfel 12eaa944a7 feat: read-only waveform strip + manual refresh in the Audio tab 2026-07-02 14:53:32 +02:00
Ethanfel 4c5c4276c1 feat: core/waveform — peak reduction + ffmpeg region decode 2026-07-02 14:51:47 +02:00
Ethanfel 8e03b97742 feat: extract honors format picker + fade/normalize/gain edits 2026-07-02 14:47:49 +02:00
Ethanfel c9a4ac584f feat: Audio tab QToolBox — Extract & Edit + Scan/Classify panes 2026-07-02 14:35:22 +02:00
Ethanfel 4b3b25ffae feat: audio format + fade/normalize/gain widgets; free them from the transport row 2026-07-02 14:26:06 +02:00
EthanfelandClaude Opus 4.8 52ce2d451a refactor: rename Scan deck tab -> Audio (deck wiring only)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:13:48 +02:00
Ethanfel f584e54a8c feat: audio_edit_filters helper + optional -af chain on build_audio_clip_command 2026-07-02 14:09:14 +02:00
EthanfelandClaude Opus 4.8 8f18c7a817 docs: audio tab Phase 1 implementation plan (extract/edit/classify)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:36:37 +02:00
EthanfelandClaude Opus 4.8 e14cebd081 docs: audio workspace tab design (2-phase: extract/edit/classify + dataset)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:33:19 +02:00
EthanfelandClaude Opus 4.8 eab5c690c7 feat: audio area length — remove the upper cap + step by 1s
The audio extract length is meant for visualizing/grabbing sequences that can
run minutes long, but the control capped it and stepped in fiddly 0.10s
increments. Raise the range to effectively unlimited (24h; ffmpeg stops cleanly
at end-of-file if the source is shorter) and make the arrows step 1s — typing
still allows sub-second precision. Widen the field for the larger values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 01:11:57 +02:00
EthanfelandClaude Opus 4.8 4445f0e7f4 fix: audio extract honored a silent length clamp — 30s near the end became 3s
_on_extract_audio clamped the duration to (timeline_duration - cursor), so with
the playhead within the requested length of the end (or any under-reported
duration) a 30s request was silently truncated to whatever remained — the user
asked for 30s and got 3s with no indication why.

Drop the clamp: pass the requested length straight to ffmpeg, which stops
cleanly at end-of-file if the source is shorter. Then ffprobe the result and,
when it comes up short, say so ("Saved 3.0s — source ended before 30.0s
requested") instead of silently shrinking. When there's room, 30s now yields
exactly 30s.

Adds core.ffmpeg.probe_duration(). Verified end-to-end: a fitting request
returns the exact length; a genuine near-end request returns the available
audio (rc=0) and is reported as truncated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:07:35 +02:00
EthanfelandClaude Opus 4.8 ed63d04abf feat: Extract audio area — exact-length audio slice from the playhead, save-as
A dedicated "♪ Extract audio" button on the transport row grabs an exact
length of audio (set via the adjacent length box, from the playhead) and opens
a Save As dialog. Output format follows the chosen extension — WAV (pcm_s16le),
MP3 (libmp3lame), FLAC, m4a/aac, ogg/opus — re-encoding as needed; unknown
extensions let ffmpeg pick from the container.

- core.ffmpeg.build_audio_clip_command(input, start, duration, out_path):
  fast-seek + exact -t duration + -vn, codec by extension. Verified end-to-end
  (wav/mp3/flac all land at exactly the requested duration).
- Timeline shows the audio area as a distinct teal dashed band spanning
  [cursor, cursor+length], updated live as the playhead or length changes, so
  you see exactly what will be extracted.
- Length + last save dir persist in QSettings; button enabled once a file loads.

Tests: 3 core (codec-by-extension, exact length, case-insensitive) + 2 GUI
(controls exist, band tracks cursor/length).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 23:48:24 +02:00
EthanfelandClaude Opus 4.8 7ae1720b9e fix: subcategory export buttons hidden by ghost entries + give them their own centered row
Two issues with the per-subprofile (subcategory) export buttons:

1. Visibility was decided by a fuzzy `f.endswith("_" + suffix)` match against
   the hidden-subcats set. A ghost "_blowjob" (empty-base leftover from the
   trailing-slash folder bug) or an unrelated "mp4_no_clap" would match and
   hide the wrong button — so enabling a subcategory in the Sub menu never
   revealed its export button. Match the exact "<base>_<suffix>" folder name
   instead (same name the menu shows and _hidden_subcats stores).

2. The buttons were crammed into the transport row after Export. Move them to
   their own row with stretches on both ends so the (often many) "▸ name"
   buttons stay centered and out of the transport controls.

Also cleared the polluted hidden_subcats/POV_Front set in the user's QSettings
(ghost "_*" names + a hide-all'd set of real "mp4_*"), so every subcategory is
visible again. Regression test added for the exact-match predicate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:19:20 +02:00
EthanfelandClaude Fable 5 514607eddd fix: harden export-folder base derivation against a trailing slash
A folder ending in "/" made os.path.basename() return "", so subprofile
folders/labels became "_blowjob" instead of "mp4_blowjob" — cluttering the
subcategory menu and breaking the marker↔category match. rstrip the trailing
separator in _tab_export_folder and the three basename(_txt_folder) sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 13:54:32 +02:00
EthanfelandClaude Fable 5 4299de5f97 fix: keep length control + mode in sync on every active-tab switch; dup preserves LTX-2; auto-export + frames use legal LTX-2 length
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 15:35:46 +02:00
EthanfelandClaude Fable 5 86ab606059 docs: changelog + README for LTX-2 mode + tab features (v1.2)
Bump APP_VERSION to 1.2 and add a 1.2 changelog entry covering the
per-tab export folder + mismatch guardrail, Duplicate tab, and LTX-2
export mode. README Interface section gains matching bullets.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:55:05 +02:00
EthanfelandClaude Fable 5 1eb7de2a1a fix: duplicate-tab folder is a sibling, not a child, when source ends in /
".../AlexisCrystal/" + "_copy" was producing ".../AlexisCrystal/_copy"; rstrip
the trailing separator first → ".../AlexisCrystal_copy". Regression test uses a
trailing-slash source folder.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:36:47 +02:00
EthanfelandClaude Fable 5 4715c0ce49 fix: sync export folder when selecting a file in a side-by-side list; tighten guardrail; rename per-tab attr
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 01:06:21 +02:00
EthanfelandClaude Fable 5 e5ce59c065 feat: bind export folder to each file-list tab + export-folder mismatch guardrail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 00:56:55 +02:00
EthanfelandClaude Fable 5 cbbdfeadb1 feat: logo-based icon set + accent aligned to brand palette
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:56:54 +02:00
EthanfelandClaude Fable 5 8a7d761815 chore: drop stale scaffolding comments flagged in final review
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:47:41 +02:00
EthanfelandClaude Fable 5 140a424469 docs: changelog + README for the UI overhaul (v1.1)
Bump APP_VERSION to 1.1 with a "What's new" entry covering the menu bar,
tabbed control deck, side-by-side panels, status bar, and visual polish.
Add an Interface section to the README. Shortcuts unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:39:53 +02:00
EthanfelandClaude Fable 5 bc6e30a2d4 change: deck split shows exactly the pinned panels (no leftover column)
Pinning 2 of 3 panels previously showed a 3rd "leftover" tab-column, which
read as all-three-pinned and was confusing. Now the split view shows exactly
the pinned panels (pin 2 -> 2 columns, pin 3 -> 3). Adds an always-available
View > Side-by-side panels submenu of checkable toggles as the way to pin a
panel while already in split view (the right-click-tab gesture only works in
tabbed mode). Tests assert exactly-N-columns and the menu-pin path; the win
fixture now resets deck state so tests don't depend on persisted layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:26:35 +02:00
EthanfelandClaude Fable 5 2ea3a9149a fix: allow pinning the 3rd deck panel from split mode; dedupe header height
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:50:04 +02:00
EthanfelandClaude Fable 5 e820c106af test: structure tests for control-deck side-by-side mode
- test_deck_stack_exists: _deck_stack present; default shows _control_deck.
- test_pinning_two_panels_switches_to_split: pin 2 panels + refresh →
  stack shows _deck_split_container.

Pin via _pinned flags directly (not the toggle handler) so no QSettings
write leaks into other function-scoped windows; existing 6 tests run in
default/tabbed state and still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:36:08 +02:00
EthanfelandClaude Fable 5 780832d4aa feat: side-by-side mode for the control deck (pin panels into columns)
Mirror the playlist pin→side-by-side pattern for the Export / Crop &
Track / Scan control-deck panels. Right-click a deck tab → "Show
side-by-side"; pinning 2+ panels lays them out as resizable QSplitter
columns, with any unpinned panel kept reachable in a leftover tab-column.
The ✕ header returns a panel to tabs. State persists across launches via
the deck_pinned QSettings key.

- _DeckTabBar: minimal QTabBar emitting pin_toggle_requested(idx).
- _build_control_deck wraps _control_deck + a split container in a
  QStackedWidget (_deck_stack), mounted in right_layout in its place;
  sets _pinned/_label/_deck_key on each page; builds _deck_panels.
- _refresh_deck_layout / _detach_deck_panels / _clear_deck_split /
  _on_deck_pin_toggle / _on_deck_unpin / _save_deck_layout, guarded by
  _deck_loading. Reparented pages are setVisible(True) so they don't
  render blank (same gotcha the playlist documents).
- Restore block at the end of __init__ reads deck_pinned (str/list).
- Height-pin now targets _deck_stack and fits the tallest split-mode
  column (22px header + content) so split mode never clips.

Default (nothing pinned) behaves exactly like the prior tabbed deck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:36:03 +02:00
EthanfelandClaude Fable 5 6037f15e7b docs: multi-pane control deck design + plan addendum
Pin deck panels (Export/Crop/Scan) side-by-side as resizable columns,
mirroring the playlist pin pattern; unpinned panels stay reachable as a
tab-column. Spec for the multi-pane feature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:31:22 +02:00
EthanfelandClaude Fable 5 035eaf3894 style: unified theme, primary Export, group separators, clearer labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:19:25 +02:00
EthanfelandClaude Fable 5 35ea1baec8 fix: keep Subprofiles▸Remove menu in sync with subprofile changes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:15:07 +02:00
EthanfelandClaude Fable 5 6a71386ed8 fix: robust deck height, state-aware Scan/Train menu items, import cleanup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:04:15 +02:00
EthanfelandClaude Fable 5 d1fb35af8e refactor: populate Crop & Scan tabs; menu-only buttons hidden; drop settings row
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:54:10 +02:00
EthanfelandClaude Fable 5 c55693094d refactor: add control deck; move export/encode controls into Export tab
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:48:26 +02:00
EthanfelandClaude Fable 5 5832d08b26 feat: real status bar replaces inline status label
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:37:52 +02:00
EthanfelandClaude Fable 5 b4cfa7561a fix: resolve menu-bar shortcut collision, checkmark desync, brittle test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:33:05 +02:00
EthanfelandClaude Fable 5 0ccc29709e feat: add menu bar wired to existing handlers; move profile selector and help into menu-bar corner
Adds MainWindow._build_menubar building File/Edit/Scan/View/Help menus
whose actions reuse the existing handler methods. Profile combo and the
? shortcuts button move from top_bar into a TopRightCorner widget. Adds
_show_about and _rebuild_remove_subprofile_menu helpers. Bidirectional
sync for Hide exported / Show hidden; forward-only sync for Review mode
(reverse added in a later stage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:24:24 +02:00
EthanfelandClaude Fable 5 7e917d00a6 test: add MainWindow structure smoke test (skips headless)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:22:49 +02:00
EthanfelandClaude Fable 5 2ffb81eaa3 docs: UI restructure design + implementation plan
Tabbed control deck reorg of MainWindow: menu bar for rare actions,
always-visible transport bar, 3-tab control deck (Export / Crop & Track /
Scan), real status bar, plus a visual-polish pass. No behavior, shortcut,
or core/ changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:18:47 +02:00
EthanfelandClaude Opus 4.6 b448085242 fix: many more distinct subcategory marker colors (24, was 5)
Subcategory marker groups cycled through only 5 colors, so the 6th repeated.
Generate 24 colors across the hue wheel, interleaved (coprime step) so
consecutive groups are ~105 deg apart and colors only repeat after 24.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-11 15:02:16 +02:00
EthanfelandClaude Opus 4.6 7cf90c1e5c feat: jump playback to 3s before the new end when the play area shrinks
When autoclip (A / back button) or a wheel scroll reduces the clip span, seek
playback to 3s before the new end and loop there so the shorter cut point can
be reviewed immediately. Growing the play area is unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 13:14:10 +02:00
EthanfelandClaude Opus 4.6 5aa6878cf6 fix: pin mpv speed every playback tick so it can't drift to half
The x2/x4 toggle always derives the right speed (1/2/4), but mpv's speed could
still drift out of sync during the ab-loop playback (e.g. across loop seeks),
making it feel half-speed after x2 -> x4 -> x2. Reassert the desired speed on
each render tick while playing so mpv always matches the buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 11:45:56 +02:00
EthanfelandClaude Opus 4.6 0e903812fa feat: discreet ghost mark at the cursor's previous position
When you move the highlight start (click/drag the timeline), leave a single
faint dashed line at where it was, so an accidental move is easy to undo by
eye. Only the most recent prior position is kept, it's suppressed when leaving
an already-exported spot (it has a marker), and it clears on a new file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 11:17:56 +02:00
EthanfelandClaude Opus 4.6 d23ae2e88a fix: speed (x2/x4) stays in sync with playback; reclick resumes 1x
The clip preview loops via ab-loop; mpv's speed could drift out of sync with
the x2/x4 buttons, so toggling a speed off didn't reliably return to normal.
Centralize the desired speed in MpvWidget and reapply it on every play_loop,
and derive the effective speed from the button state on each click so a
re-click cleanly returns to 1x.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 22:29:16 +02:00
EthanfelandClaude Opus 4.6 d97de8de10 feat: mouse back side-button triggers autoclip on the timeline
The lower/back side button (Qt BackButton) fits the clip count to the current
play position — same as the A hotkey / autoclip. Forward button left unused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 22:04:31 +02:00
EthanfelandClaude Opus 4.6 c6673228fa change: right-click = delete menu, middle-click = lock, wheel = ±clips
- Restore right-click to the marker/keyframe delete menu (no longer toggles
  lock); kept the no-seek guard so it doesn't nudge the cursor.
- Middle-click (no drag) now toggles cursor lock; middle-drag still pans.
- Plain mouse wheel over the timeline adds/removes clips (up +1, down -1,
  clamped); Ctrl+wheel still zooms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 21:55:53 +02:00
EthanfelandClaude Opus 4.6 fa4104eded fix: middle/right click no longer scrub the timeline cursor
mousePressEvent fell through to _seek for right-click, and mouseMoveEvent
scrubbed on any held button — so a middle/right click that nudged the mouse a
pixel moved the cursor. Right-click now returns early in press/release, and the
drag-seek in mouseMoveEvent is restricted to the left button. Middle still
bumps the clip count, right still toggles lock / opens the delete menu, left
still scrubs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 21:28:46 +02:00
EthanfelandClaude Opus 4.6 9f7d2e1185 feat: timeline right-click toggles lock, middle-click bumps clip count
- Right-click on empty timeline toggles cursor lock (G). Right-clicking a
  marker/keyframe still opens the delete menu.
- Middle-click (press+release without dragging) adds one to the clip count
  and wraps at the max; middle-drag still pans the zoomed view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 21:24:52 +02:00
EthanfelandClaude Opus 4.6 c2e6c62c00 fix: timeline hotkeys keep working after clicking buttons / setting spinboxes
A focused QPushButton swallows Space/Enter and a focused spin box swallows
every key (its line edit also makes _KeyFilter suppress the app shortcuts), so
clicking Export or the clip-count spinner left the timeline hotkeys dead.

- Give all main-window buttons (incl. dynamic subprofile/format/unpin buttons)
  NoFocus so they never trap keyboard focus.
- Spin boxes clearFocus on editingFinished so hotkeys resume after Enter
  (clicking elsewhere already releases focus via _KeyFilter).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 21:06:32 +02:00
EthanfelandClaude Opus 4.6 8aa8d8805b perf: background the scan-panel DB reads on file load
load_for_file no longer runs three DB queries on the UI thread during file
load. A _ScanLoadWorker reads the bundle (hard negatives, scan-export times,
latest scan results) via its own short-lived connection — safe alongside the
main connection now that WAL is on. The table rebuild stays on the UI thread
in _on_scan_bundle_loaded; the timeline scan regions are synced from the new
loaded(filename) signal. Stale results from rapid file switches are ignored,
and the worker is drained on shutdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 20:16:47 +02:00
EthanfelandClaude Opus 4.6 35c67f4bd5 perf: single-pass get_training_stats (was O(folders × rows))
Group clips by export folder in one scan instead of re-scanning every row for
each folder; also drops the extra get_export_folders() query. Speeds up the
train-dialog stats with many subcategories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 19:52:13 +02:00
EthanfelandClaude Opus 4.6 b738a19304 perf: cut DB scans, timeline repaints, and per-frame allocations
Database:
- Enable WAL + synchronous=NORMAL + bigger cache pragmas
- Add (profile, filename) index covering the hot queries
- _refresh_playlist_checks: one get_clip_counts_grouped() scan for the whole
  profile instead of one query per file (was O(N) full scans per keystroke/
  tab switch/file load)

Timeline (60fps playback):
- set_play_position only repaints when the playhead moves a whole pixel or the
  view scrolls (≈30x fewer full repaints in non-zoomed playback)
- Cache all per-paint QColor/QPen objects and the other-folder color table in
  __init__ instead of allocating them every frame; drop the per-paint
  visible-markers list comprehension

File load / startup:
- PlaylistWidget stats files for the missing-set only when paths change, not on
  every filter keystroke
- Cache the vid-folder lookup (DB + os.listdir) per (file, folder) so spinner
  ticks don't repeat it; m-counter still recomputed so it stays correct
- Swap the waveform worker without blocking the UI thread (no wait(1000))
- Defer the changelog modal so the window is interactive first

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 19:50:41 +02:00
EthanfelandClaude Opus 4.6 dbd8e6a8ac fix: opened/dropped files go to the visible tab, not the last-interacted one
_on_open_files/dropEvent added to self._playlist (the last-interacted pane,
which could be the tab whose file is loaded in the player). Now they target
_add_target_playlist(): the currently visible tab in tab view, or the active
pane in side-by-side, and make it the active list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 17:04:31 +02:00
EthanfelandClaude Opus 4.6 73dfea4ae9 fix: show() reparented lists in side-by-side so they lay out
QTabWidget hides its non-current pages. Pinning a tab that wasn't the active
one reparented a hidden QListWidget into the split panel, where it stayed
hidden — the layout collapsed it, overlapping the header in the middle with
an empty/black list. setVisible(True) after reparenting fixes the panes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 16:38:47 +02:00
EthanfelandClaude Opus 4.6 2170e72cbd fix: usable side-by-side layout; make tab→folder a per-tab option
Side-by-side layout:
- Reduce pinned lists' min width (200→60) so two fit, widen the main
  splitter's left section when split is active (steal from the video pane)
  and restore on collapse. Fixed-height headers, list stretch, rebuild on
  reparent — fixes the empty/misplaced panes.

Tab→folder per tab:
- Replace the global "Tab→folder" checkbox with a per-tab toggle in the tab
  right-click menu ("Export to tab-named folder"). _tab_export_folder() now
  reads the active tab's flag; persisted per tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 16:28:26 +02:00
EthanfelandClaude Opus 4.6 c9915914c4 fix: rename tab export-folder helper to avoid clobbering self._export_folder
self._export_folder is an existing str attribute (stashed during export), so
the new _export_folder() method shadowed it and 'str' object is not callable
crashed on startup at _update_next_label. Renamed to _tab_export_folder().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 16:19:50 +02:00
EthanfelandClaude Opus 4.6 251747bb0b feat: side-by-side pinned tabs + optional tab-name in export folder
Side-by-side:
- Right-click a tab → "Show side-by-side" to pin it; 2+ pinned tabs render
  in a horizontal splitter (each with a name header + ✕ to unpin), via a
  QStackedWidget that swaps between the tab view and split view.
- Tabs are now backed by self._pws (source of truth) so persistence and
  layout work regardless of where each list is parented; pinned state saved.
- self._playlist resolves to the last-interacted pane (active pw), so exports
  and edits follow the pane you're acting in. Pinned flag persisted per profile.

Tab → export folder (optional):
- New "Tab→folder" checkbox: when on, the active tab's name is appended to the
  export folder (mp4 → mp4_BatchA). Default "List N" tabs are ignored.
- _export_folder()/_export_base_name() helpers route exports, markers, next
  label, and delete/clear paths so they stay consistent with the tab folder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 16:13:26 +02:00
EthanfelandClaude Opus 4.6 13c4d3f7f6 fix: keep separators when removing their anchor file; add Copy name
- _remove_paths re-anchors a separator to the next surviving file (or a
  trailing separator) instead of dropping it when its anchor is removed;
  used by both Remove and Delete-from-disk. Removal now persists tabs.
- Add "Copy name" / "Copy N names" to the playlist context menu (basenames
  to clipboard, newline-joined for multi-select).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 15:55:42 +02:00
EthanfelandClaude Opus 4.6 1d49ce7cee perf: run waveform ffmpeg at low priority so it yields to mpv on load
The first load of a file decodes the whole audio track in a background
thread; nice'ing it (os.nice(15)) reduces disk/CPU contention with mpv
during the initial open. Result is cached, so subsequent loads are fast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 12:14:04 +02:00
EthanfelandClaude Opus 4.6 109bc658c3 feat: flag playlist files missing from disk (⚠ orange strikethrough)
Missing files are kept in the list instead of being silently dropped on load,
and styled distinctly with a tooltip. add_files gains allow_missing; tab
restore keeps missing entries so they're visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 12:12:03 +02:00
EthanfelandClaude Opus 4.6 ec7138f51b feat: single Disable all / Enable all for every subcategory at once
Replace the per-folder submenu/buttons with one batch action: "Disable all"
moves every enabled subcategory (excluding the main folder and already-disabled
ones) to _disabled in one click; "Enable all" restores them. Available in both
the playlist right-click menu and the Sub button menu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 14:19:49 +02:00
EthanfelandClaude Opus 4.6 68c633ab46 feat: add "Disable all in" / "Enable all in" to playlist right-click menu
Folder-wide disable/enable is now reachable right next to the per-video
"Disable in" submenu, listing every subcategory in the profile (not just
ones tied to the loaded video). Backed by profile-wide subcategory counts
pushed to the playlist in _refresh_playlist_checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 14:14:42 +02:00
EthanfelandClaude Opus 4.6 d0a94e7b68 fix: Sub menu lists all profile subcategories so Disable/Enable all is reachable
Previously the Sub menu only showed folders from the current video's markers
plus configured subprofiles, so subcategories without clips on the loaded
video (or without a matching subprofile) never appeared. Now it also includes
every subcategory that has clips anywhere in the profile (active or disabled).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 14:11:11 +02:00
EthanfelandClaude Opus 4.6 632c2dc076 feat: disable/enable all clips in a subcategory folder at once
- Sub menu now has per-folder "Disable all" / "Enable all" buttons with live counts
- relocate_video_clips accepts filename=None to move every video's clips in a folder
- get_all_folder_counts returns profile-wide per-folder counts (incl _disabled)
- Disable-all confirms before moving; both refresh markers + playlist counts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 14:08:20 +02:00
EthanfelandClaude Opus 4.6 0f335c5e66 feat: tabbed file lists with editable labels
- Wrap the playlist in a QTabWidget; each tab is its own file list
- "+" corner button adds tabs; double-click a tab to rename inline; tabs are closable (last tab protected) and movable
- self._playlist now resolves to the active tab's PlaylistWidget
- Persist tabs (label + files + separators) per profile as JSON; falls back to legacy session_files/separators on first load
- Filter box and playlist filters apply to the active tab; tab switches reapply filters and refresh marks
- Profile switch/duplicate/delete now save/load/copy/remove per-profile tab state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 13:30:18 +02:00
EthanfelandClaude Opus 4.6 f1f8fd5244 feat: playlist separator can be added above or below a file
- Context menu offers both "Add/Remove separator above" and "below"
- "Below" anchors to the next visible file, or a trailing line via end sentinel when clicking the last file
- End sentinel preserved across rebuilds and persisted per profile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 12:47:43 +02:00
EthanfelandClaude Opus 4.6 299779cf29 feat: disable videos per-subcategory, named models, multi-category training, playlist separators
- Train dialog: multi-select positive subcategories via checkbox list, optional model name suffix ({profile}_{model}_{name}.joblib)
- list_trained_models recognizes named model variants
- Disable a video per-subcategory: moves its clips to a sibling {subcat}_disabled folder, rewrites DB output_path, migrates dataset.json, marks the name red
- Disabled clips excluded from training, stats, timeline, and playlist counts
- Playlist per-video count reflects only visible, non-disabled subcategories
- Persist subcategory show/hide visibility per profile across restarts
- Add/remove playlist separator rows (right-click) to mark batches, persisted per profile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 12:45:03 +02:00
EthanfelandClaude Opus 4.6 56218c18f4 feat: speech detection, format export buttons, subcategory controls, crop overlay during playback
- Add speech detection via faster-whisper with red waveform coloring for speech regions
- Add format variant export buttons (P/S) next to Export and subprofile buttons when portrait/square enabled
- Add force_ratio parameter to _on_export for deterministic format exports
- Add subcategory show/hide with persistent checkbox menu (no longer closes on toggle)
- Show crop overlay lines during video playback, not just when paused
- Delete marker now also removes files from disk and cleans up annotations
- Clear all markers also deletes files and DB entries
- Add playlist text filter, clip spread tick lines on timeline
- Fix LD_PRELOAD for GLIBCXX in conda launcher

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 18:23:43 +02:00
EthanfelandClaude Opus 4.6 2c45aff668 feat: add delete-from-disk option in playlist context menu
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 11:38:37 +02:00
EthanfelandClaude Opus 4.6 07e2f733b9 feat: bulk update source paths in train dialog
Add ProcessedDB.update_source_paths() to re-resolve missing or stale
source_path entries by matching filenames against a directory listing
and the current playlist. Exposed as "Update paths" button in the
train dialog next to the video dir field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-09 13:47:48 +02:00
EthanfelandClaude Opus 4.6 8c5a4c4524 fix: marker labels show actual m-number from filename instead of time order
Extract the manual export counter (m1, m2, ...) from the output path
so timeline markers match their filenames. Falls back to sequential
numbering for old-format paths without m-prefix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 11:42:15 +02:00
EthanfelandClaude Opus 4.6 4e5b631efb fix: right-click delete works on other-folder markers too
The context menu hit test only searched the current folder's markers.
Now also checks other-folder markers so the delete option appears
for subprofile markers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 11:38:49 +02:00
EthanfelandClaude Opus 4.6 ec77b8224f feat: show other-folder markers in distinct colors on timeline
Subprofile/subfolder exports now appear as colored markers (yellow,
green, blue, purple, orange) with their own numbering, separate from
the main folder's red markers. Each folder gets its own color and
independent sequence numbers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 11:36:38 +02:00
EthanfelandClaude Opus 4.6 9becd5a06d fix: filter timeline markers by current export folder
Subprofile exports (folder_suffix) created markers that interleaved
with main folder markers, shifting their numbering. Now get_markers
and _get_markers_for accept an export_folder parameter and use
SQL LIKE to only return markers whose output_path is in that folder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 11:32:39 +02:00
EthanfelandClaude Opus 4.6 fae5560e2d feat: overview scrollbar on timeline when zoomed in
Thin 8px scrollbar appears above the ruler when the timeline is zoomed.
Shows a draggable thumb representing the current view window. Click
outside the thumb to jump, drag the thumb to pan. Ruler and track
shift down to make room. Scrollbar hidden when not zoomed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 11:47:37 +02:00
EthanfelandClaude Opus 4.6 07e3a1223c fix: unpack 4-tuple markers in export overlap check
The marker format was extended to include clip_span but the overlap
check in _on_export still unpacked 3 values, causing a crash on export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 11:43:15 +02:00
EthanfelandClaude Opus 4.6 3af6e05fb7 fix: use exact seeking instead of keyframe-based seeking
mpv's "absolute" seek lands on the nearest keyframe before the target,
causing playback to start ~3s before the marker. Switch to
"absolute+exact" for both seek() and play_loop() so playback starts
at the precise requested time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 11:39:57 +02:00
EthanfelandClaude Opus 4.6 d787871735 fix: auto-pan timeline to follow playback position when zoomed in
Revert span opacity back to 35 (was fine). The actual issue was the
play position line disappearing when scrolled out of the zoomed view.
Now set_play_position auto-pans the view window to keep the playback
marker visible with a 10% margin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 11:36:25 +02:00
EthanfelandClaude Opus 4.6 85c08d7c48 fix: seek to exact marker position on click, increase clip span visibility
- _on_marker_clicked now explicitly sets cursor and seeks mpv to start_time
  instead of relying on the timeline's indirect seek chain
- Doubled clip span area opacity (35 → 70) so spans are always visible
- Trigger end-frame preview after config restoration on marker click

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 11:34:36 +02:00
EthanfelandClaude Opus 4.6 f6966a092a feat: per-profile playlists, marker span display, precise marker seek
- Per-profile playlist persistence (session_files/{profile} in QSettings)
- Training data resolves source videos via playlist paths before fallback dir
- Guard against deleted video files in _load_file
- Fix marker double-click to seek to exact marker time instead of click pixel
- Show manual clip spans as light amber areas on the timeline
- Extend marker tuples with clip_span from DB (clip_duration + overlap)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 17:11:50 +02:00
EthanfelandClaude Opus 4.6 7cee3ab768 fix: default embedding model to EAT_LARGE
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 15:49:51 +02:00
EthanfelandClaude Opus 4.6 47f910644d feat: configurable clip duration, playback speed, Windows WId embedding
Add clip duration spinner (2–30s, default 8s) replacing all hardcoded
8.0 references. Store clip_duration in DB for accurate re-export span
calculations. Add x2/x4 playback speed toggle buttons. On Windows, mpv
renders directly into the widget's native window handle (WId embedding)
instead of slow FBO readback; crop overlays use a transparent child
widget. Fix _poll_render crash when player is None after closeEvent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 15:18:37 +02:00
EthanfelandClaude Opus 4.6 e972c7a2ae feat: re-export rework, delete profile, shared path protection
Re-export dialog now offers two modes: keep section length (adjust clip
count) or keep clip count (adjust section length). Files shared with
other profiles are preserved during re-export. Vid folder is resolved
before DB deletions to reuse existing folders. Add delete profile option
with confirmation dialog. Profile duplication now copies all tables
including processed exports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 14:57:54 +02:00
EthanfelandClaude Opus 4.6 cb805c5bda feat: add re-export button and duplicate profile option
Re-export button (next to Spread spinner) re-exports all manual clips
for the current file into the current folder with the new spread value.
Old files are deleted from their original locations first.

Duplicate profile option in the profile dropdown copies scan_results,
hard_negatives, and hidden_files to a new profile name (exports are not
copied since they reference file paths tied to the source profile).

Also widened get_profiles() to include profiles that only have
scan_results or hard_negatives, not just exports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 08:24:13 +02:00
EthanfelandClaude Opus 4.7 bf14247b00 feat: auto-pan timeline to selected scan region when zoomed
When a scan result row is clicked, if the active region falls outside
the current zoomed view the view centers on the region (and widens if
the region is larger than the current span).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:49:55 +02:00
EthanfelandClaude Opus 4.7 73396659dc feat: add timeline zoom and pan for precise edge editing
Ctrl+scroll zooms the timeline view around the mouse. Middle-mouse drag
pans when zoomed. Scrolling all the way out clamps back to full view.

While dragging a scan region edge with Shift, the view auto-pans when
the mouse approaches the widget border so you can extend a region past
the visible range.

All paint and hit-test paths now route through _time_to_x / _pos_to_time
helpers backed by a _view_start / _view_span window, so existing
interactions (seek, marker click, edge resize, keyframe context menu)
all adapt naturally to the zoom level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:41:56 +02:00
EthanfelandClaude Opus 4.7 c8bc629419 feat: merge scan rows and strengthen Ctrl+Z undo
Add "Merge N rows" context-menu option that combines selected scan rows
into one (min start, max end, max score), with full undo support.

Ctrl+Z is now an application-wide shortcut so it works regardless of
which widget has focus. Negatives undo now respects the exported-green
row color instead of reverting to default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:20:06 +02:00
EthanfelandClaude Opus 4.7 de8840e1eb feat: adapt export button for selection; show markers in review mode
- Scan panel button now reads "Export Selected (N)" while rows are
  selected, mirroring the clip-count estimate used for full exports.
  Selection changes fire an explicit signal so the label refreshes.
- Export markers remain visible on the timeline in scan/review mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 13:12:00 +02:00
EthanfelandClaude Opus 4.7 def966a913 feat: delete-export right-click and partial scan export on selection
- Right-click on exported (green) rows shows "Delete export" to wipe
  associated clip files, annotations, DB rows and empty vid folders;
  scan panel, markers and playlist badge refresh afterwards.
- Exporting with rows selected in the scan panel now runs a partial
  export: prior scan exports are preserved, and the area index for new
  clip filenames is offset past existing a-suffixes in the vid folder
  to avoid collisions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 13:04:01 +02:00
EthanfelandClaude Opus 4.7 bc4ae21153 feat: color exported scan result rows green
Scan panel rows whose range contains an exported clip's start time
are colored green. Priority: disabled > negative > exported > default.
Exported state refreshes automatically after an auto-export batch
completes on the current file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 12:50:12 +02:00
EthanfelandClaude Opus 4.7 a731fbfc32 feat: highlight active scan region on timeline when row clicked
Draws a yellow outline around the scan region corresponding to the
selected/clicked row, so overlapping regions can be distinguished.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 11:34:23 +02:00
34 changed files with 10640 additions and 577 deletions
+1
View File
@@ -3,6 +3,7 @@ __pycache__/
*.pyo *.pyo
.pytest_cache/ .pytest_cache/
.worktrees/ .worktrees/
.superpowers/
.venv/ .venv/
models/ models/
cache/ cache/
+1
View File
@@ -3,6 +3,7 @@
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_NAME="8cut" ENV_NAME="8cut"
CONDA_PREFIX_BASE="/media/p5/miniforge3" CONDA_PREFIX_BASE="/media/p5/miniforge3"
export LD_PRELOAD=/usr/lib/libstdc++.so.6
# 1. Try .venv in project dir # 1. Try .venv in project dir
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
+5
View File
@@ -30,6 +30,11 @@ mpv_dir = Path(os.environ.get("MPV_DIR", base))
datas = [] datas = []
# Bundled assets (icons, logo) — must exist at runtime under sys._MEIPASS/assets
assets_dir = base / "assets"
if assets_dir.exists():
datas.append((str(assets_dir), "assets"))
# YOLOv8 model (optional — large, skip if missing) # YOLOv8 model (optional — large, skip if missing)
yolo = base / "yolov8n.pt" yolo = base / "yolov8n.pt"
if yolo.exists(): if yolo.exists():
+39
View File
@@ -33,6 +33,34 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
- **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox - **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox
- **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional) - **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional)
### Audio extraction & editing
- **Extract audio area** — save an exact-length audio slice from the playhead (length steps by 1s, no upper cap)
- **Output format** — WAV / MP3 / FLAC / M4A / OGG / OPUS, remembered between sessions
- **Non-destructive edits** — fade in/out, EBU R128 normalize, and gain, applied via ffmpeg on extract (and audition)
- **Waveform preview** — a read-only strip of the current area (↻ to refresh; decode length-capped so long areas don't stall)
- **Interactive waveform** — drag the in/out handles to set the exact clip region, wheel to zoom, with a playhead during audition
- **Audition** — Play/Stop the current area with the edits applied
### Audio merge (crossfade)
- **Merge pane** — assemble multiple clips into one, crossfading every join (ffmpeg `acrossfade`)
- **Add clips** — the current audio area (with its edits) or audio files from disk; reorder or remove them
- **Crossfade length** — one global crossfade applied at each join (0 = butt-join); clips shorter than the crossfade are flagged before rendering
- **Preview / Save** — audition the merged result, or render and save-as (WAV/MP3/FLAC/…)
### Audio clip editor (destructive)
- **Edit clip** — open the current audio area in an editor; drag to select a region
- **Ops** — Delete / Silence / Reverse / Trim-to-selection, with undo/redo
- **Audition & Save** — play the result, then Save as (WAV/MP3/FLAC/…)
### Clip library
- **Library tab** — a persistent tab in the scan-results column that collects extracted audio clips (auto-added on extract + editor Save)
- **Add / drag-drop** — import existing audio files
- **Per clip** — Edit (opens the clip editor), Re-export (save-as / transcode), Play, Remove
### Audio scanning ### Audio scanning
- **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs - **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs
@@ -43,6 +71,7 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
- **Hard negatives** — mark false positives to refine training - **Hard negatives** — mark false positives to refine training
- **Model versioning** — timestamped backups with rollback support - **Model versioning** — timestamped backups with rollback support
- **Scan export** — batch export from scan results with spread and minimum duration filtering - **Scan export** — batch export from scan results with spread and minimum duration filtering
- **Location** — scan controls live in the **Audio** tab's *Scan / Classify* pane (formerly the *Scan* tab)
### Scan results panel ### Scan results panel
@@ -61,6 +90,16 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
- **Subprofiles** — lightweight export folder variants for multiple output targets - **Subprofiles** — lightweight export folder variants for multiple output targets
- **Review mode** — clean timeline view for navigating scan results without export clutter - **Review mode** — clean timeline view for navigating scan results without export clutter
### Interface
- **Menu bar** — File / Edit / Scan / View / Help hold the occasional actions (open files, train, scan all, profiles); the profile selector and shortcuts (`?`) sit in the top-right corner
- **Control deck** — a compact tabbed panel under the video groups the settings into **Export** (label, name, folder, format, resize, duration/clips/spread, workers), **Crop & Track**, and **Scan** (model, threshold, fuse, scan/auto/speech/review)
- **Side-by-side panels** — pin deck panels to view them as resizable columns: right-click a deck tab → *Show side-by-side*, or toggle them under *View ▸ Side-by-side panels*; drag the dividers to reallocate space, and the layout persists between sessions
- **Per-tab export folder** — each file-list tab remembers its own output folder; switching tabs follows that tab's folder, and a guardrail warns when the loaded video doesn't match the destination
- **Duplicate tab** — right-click a file-list tab → *Duplicate tab* to clone its files into a new tab with its own export folder
- **LTX-2 export mode** — per-tab **Foley | LTX-2** toggle (right-click a tab, shown with an `[LTX2]` badge): LTX-2 clips are frame-exact (`frames % 8 == 1`), forced to 25 fps, and center-cropped so width & height are divisible by 32 — for LTX-2 video-to-audio datasets; applies to manual, re-export, and auto-export
- **Status bar** — export/scan progress and messages, with the current file · profile · worker count always shown
## Keyboard shortcuts ## Keyboard shortcuts
| Key | Action | | Key | Action |
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g8" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffd230"/>
<stop offset="100%" stop-color="#e6a800"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="13" fill="#161616"/>
<rect x="8" y="42" width="48" height="11" rx="2" fill="#2a2a2a" stroke="#333" stroke-width="1"/>
<rect x="26" y="42" width="16" height="11" fill="#3c82dc" fill-opacity="0.45"/>
<line x1="26" y1="38" x2="26" y2="55" stroke="#ffd230" stroke-width="2"/>
<polygon points="22,38 30,38 26,44" fill="#ffd230"/>
<text x="32" y="33" font-family="'Helvetica Neue',Helvetica,Arial,sans-serif" font-size="34" font-weight="bold" fill="url(#g8)" text-anchor="middle">8</text>
</svg>

After

Width:  |  Height:  |  Size: 790 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M7.5 10 V7.5 a4.5 4.5 0 0 1 9 0 V10" stroke="#ffd230" stroke-width="2"/>
<rect x="5" y="10" width="14" height="10" rx="2" fill="#ffd230"/>
<circle cx="12" cy="14.3" r="1.4" fill="#161616"/>
<rect x="11.2" y="14.3" width="1.6" height="3.4" rx="0.8" fill="#161616"/>
</svg>

After

Width:  |  Height:  |  Size: 362 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M7.5 10 V7.5 a4.5 4.5 0 0 1 8.6 -1.8" stroke="#8a8a8a" stroke-width="2"/>
<rect x="5" y="10" width="14" height="10" rx="2" fill="#8a8a8a"/>
<circle cx="12" cy="14.3" r="1.4" fill="#1e1e1e"/>
<rect x="11.2" y="14.3" width="1.6" height="3.4" rx="0.8" fill="#1e1e1e"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="6.5" y="5" width="4" height="14" rx="1.2" fill="#ffd230"/>
<rect x="13.5" y="5" width="4" height="14" rx="1.2" fill="#ffd230"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M7 5 L19 12 L7 19 Z" fill="#ffd230" stroke="#ffd230" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 177 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aad4ff" stroke-width="2" stroke-linecap="round">
<circle cx="10.5" cy="10.5" r="6"/>
<line x1="15" y1="15" x2="20" y2="20"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffd230" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6.5" cy="6.5" r="2.6"/>
<circle cx="6.5" cy="17.5" r="2.6"/>
<line x1="8.8" y1="8" x2="20" y2="17"/>
<line x1="8.8" y1="16" x2="20" y2="7"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffd230" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4,17 10,11 14,14 20,6"/>
<polyline points="15,6 20,6 20,11"/>
</svg>

After

Width:  |  Height:  |  Size: 245 B

+4
View File
@@ -1,2 +1,6 @@
import sys, os import sys, os
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
def pytest_configure(config):
config.addinivalue_line("markers", "gui: constructs Qt widgets; needs a display")
+13 -7
View File
@@ -67,7 +67,7 @@ _EMBED_MODELS = {
"EAT": 768, "EAT": 768,
"EAT_LARGE": 1024, "EAT_LARGE": 1024,
} }
_DEFAULT_EMBED_MODEL = "WAV2VEC2_BASE" _DEFAULT_EMBED_MODEL = "EAT_LARGE"
_BEATS_CHECKPOINT = os.path.join( _BEATS_CHECKPOINT = os.path.join(
_DL_CACHE_DIR, "huggingface", "hub", _DL_CACHE_DIR, "huggingface", "hub",
@@ -674,9 +674,11 @@ def restore_model_version(version_path: str, profile_name: str = "default",
def list_trained_models(profile_name: str = "default") -> list[str]: def list_trained_models(profile_name: str = "default") -> list[str]:
"""Return embedding model names that have a trained .joblib for *profile_name*. """Return embedding model keys that have a trained .joblib for *profile_name*.
Looks for files matching ``{profile}_{MODEL}.joblib`` in the models dir. Looks for files matching ``{profile}_{KEY}.joblib`` in the models dir.
KEY is either a bare embed model name (e.g. ``EAT_LARGE``) or
``{MODEL}_{name}`` for user-named variants.
""" """
prefix = f"{profile_name}_" prefix = f"{profile_name}_"
suffix = ".joblib" suffix = ".joblib"
@@ -685,13 +687,17 @@ def list_trained_models(profile_name: str = "default") -> list[str]:
return result return result
for fname in os.listdir(_MODEL_DIR): for fname in os.listdir(_MODEL_DIR):
if fname.startswith(prefix) and fname.endswith(suffix): if fname.startswith(prefix) and fname.endswith(suffix):
model_name = fname[len(prefix):-len(suffix)] key = fname[len(prefix):-len(suffix)]
if model_name in _EMBED_MODELS: if key in _EMBED_MODELS:
result.append(model_name) result.append(key)
else:
for m in _EMBED_MODELS:
if key.startswith(m + "_"):
result.append(key)
break
# Also check legacy {profile}.joblib # Also check legacy {profile}.joblib
legacy = os.path.join(_MODEL_DIR, f"{profile_name}.joblib") legacy = os.path.join(_MODEL_DIR, f"{profile_name}.joblib")
if os.path.exists(legacy) and not result: if os.path.exists(legacy) and not result:
# Legacy model — we don't know the embed model, but it's usable
result.append("") result.append("")
return sorted(result) return sorted(result)
+584 -44
View File
@@ -1,4 +1,5 @@
import os import os
import re
import sqlite3 import sqlite3
import threading import threading
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -7,6 +8,12 @@ from pathlib import Path
from .paths import _log from .paths import _log
def _extract_m_number(output_path: str) -> int | None:
"""Extract the manual export number from a path like clip_001_m3_0.mp4."""
m = re.search(r'_m(\d+)[_.]', os.path.basename(output_path))
return int(m.group(1)) if m else None
class ProcessedDB: class ProcessedDB:
_SCHEMA_VERSION = 4 # bump when schema changes _SCHEMA_VERSION = 4 # bump when schema changes
@@ -17,6 +24,18 @@ class ProcessedDB:
self._lock = threading.Lock() self._lock = threading.Lock()
try: try:
self._con = sqlite3.connect(db_path, check_same_thread=False) self._con = sqlite3.connect(db_path, check_same_thread=False)
# Performance pragmas: WAL cuts lock contention and fsync cost,
# a bigger page cache keeps hot scans in memory.
for pragma in (
"PRAGMA journal_mode = WAL",
"PRAGMA synchronous = NORMAL",
"PRAGMA temp_store = MEMORY",
"PRAGMA cache_size = -65536", # ~64 MB
):
try:
self._con.execute(pragma)
except sqlite3.Error:
pass
self._migrate() self._migrate()
self._enabled = True self._enabled = True
_log(f"DB opened: {db_path}") _log(f"DB opened: {db_path}")
@@ -46,6 +65,7 @@ class ProcessedDB:
" crop_center REAL NOT NULL DEFAULT 0.5," " crop_center REAL NOT NULL DEFAULT 0.5,"
" format TEXT NOT NULL DEFAULT 'MP4'," " format TEXT NOT NULL DEFAULT 'MP4',"
" clip_count INTEGER NOT NULL DEFAULT 3," " clip_count INTEGER NOT NULL DEFAULT 3,"
" clip_duration REAL NOT NULL DEFAULT 8.0,"
" spread REAL NOT NULL DEFAULT 3.0," " spread REAL NOT NULL DEFAULT 3.0,"
" profile TEXT NOT NULL DEFAULT 'default'," " profile TEXT NOT NULL DEFAULT 'default',"
" source_path TEXT NOT NULL DEFAULT ''," " source_path TEXT NOT NULL DEFAULT '',"
@@ -63,6 +83,7 @@ class ProcessedDB:
"crop_center": "REAL NOT NULL DEFAULT 0.5", "crop_center": "REAL NOT NULL DEFAULT 0.5",
"format": "TEXT NOT NULL DEFAULT 'MP4'", "format": "TEXT NOT NULL DEFAULT 'MP4'",
"clip_count": "INTEGER NOT NULL DEFAULT 3", "clip_count": "INTEGER NOT NULL DEFAULT 3",
"clip_duration": "REAL NOT NULL DEFAULT 8.0",
"spread": "REAL NOT NULL DEFAULT 3.0", "spread": "REAL NOT NULL DEFAULT 3.0",
"profile": "TEXT NOT NULL DEFAULT 'default'", "profile": "TEXT NOT NULL DEFAULT 'default'",
"source_path": "TEXT NOT NULL DEFAULT ''", "source_path": "TEXT NOT NULL DEFAULT ''",
@@ -76,6 +97,11 @@ class ProcessedDB:
self._con.execute( self._con.execute(
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)" "CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
) )
# Most hot queries filter by profile, often with filename too.
self._con.execute(
"CREATE INDEX IF NOT EXISTS idx_profile_filename"
" ON processed(profile, filename)"
)
self._con.execute( self._con.execute(
"CREATE TABLE IF NOT EXISTS hidden_files (" "CREATE TABLE IF NOT EXISTS hidden_files ("
" filename TEXT NOT NULL," " filename TEXT NOT NULL,"
@@ -232,7 +258,8 @@ class ProcessedDB:
label: str = "", category: str = "", label: str = "", category: str = "",
short_side: int | None = None, portrait_ratio: str = "", short_side: int | None = None, portrait_ratio: str = "",
crop_center: float = 0.5, fmt: str = "MP4", crop_center: float = 0.5, fmt: str = "MP4",
clip_count: int = 3, spread: float = 3.0, clip_count: int = 3, clip_duration: float = 8.0,
spread: float = 3.0,
profile: str = "default", source_path: str = "", profile: str = "default", source_path: str = "",
scan_export: bool = False) -> None: scan_export: bool = False) -> None:
if not self._enabled: if not self._enabled:
@@ -242,16 +269,60 @@ class ProcessedDB:
"INSERT INTO processed" "INSERT INTO processed"
" (filename, start_time, output_path, label, category," " (filename, start_time, output_path, label, category,"
" short_side, portrait_ratio, crop_center, format," " short_side, portrait_ratio, crop_center, format,"
" clip_count, spread, profile, source_path, scan_export, processed_at)" " clip_count, clip_duration, spread, profile, source_path,"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", " scan_export, processed_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(filename, start_time, output_path, label, category, (filename, start_time, output_path, label, category,
short_side, portrait_ratio, crop_center, fmt, short_side, portrait_ratio, crop_center, fmt,
clip_count, spread, profile, source_path, clip_count, clip_duration, spread, profile, source_path,
1 if scan_export else 0, 1 if scan_export else 0,
datetime.now(timezone.utc).isoformat()), datetime.now(timezone.utc).isoformat()),
) )
self._con.commit() self._con.commit()
def update_source_paths(self, new_dir: str,
playlist_paths: list[str] | None = None,
profile: str = "") -> int:
"""Re-resolve source_path for all rows whose current path is missing.
Checks *new_dir* and *playlist_paths* by filename match.
Returns the number of rows updated.
"""
if not self._enabled:
return 0
lookup: dict[str, str] = {}
if playlist_paths:
for p in playlist_paths:
lookup[os.path.basename(p)] = p
if new_dir and os.path.isdir(new_dir):
for f in os.listdir(new_dir):
fp = os.path.join(new_dir, f)
if os.path.isfile(fp):
lookup[f] = fp
if not lookup:
return 0
query = "SELECT DISTINCT filename, source_path FROM processed"
params: tuple = ()
if profile:
query += " WHERE profile = ?"
params = (profile,)
rows = self._con.execute(query, params).fetchall()
updated = 0
with self._lock:
for fn, sp in rows:
if sp and os.path.exists(sp):
continue
new_path = lookup.get(fn)
if new_path and os.path.isfile(new_path):
self._con.execute(
"UPDATE processed SET source_path = ? WHERE filename = ?",
(new_path, fn),
)
updated += 1
if updated:
self._con.commit()
return updated
def get_labels(self) -> list[str]: def get_labels(self) -> list[str]:
"""Return distinct non-empty labels ordered by most recently used.""" """Return distinct non-empty labels ordered by most recently used."""
if not self._enabled: if not self._enabled:
@@ -278,19 +349,37 @@ class ProcessedDB:
cur.row_factory = sqlite3.Row cur.row_factory = sqlite3.Row
row = cur.execute( row = cur.execute(
"SELECT label, category, short_side, portrait_ratio, crop_center, format," "SELECT label, category, short_side, portrait_ratio, crop_center, format,"
" clip_count, spread" " clip_count, clip_duration, spread"
" FROM processed WHERE output_path = ?", " FROM processed WHERE output_path = ?",
(output_path,), (output_path,),
).fetchone() ).fetchone()
return dict(row) if row else None return dict(row) if row else None
def delete_by_output_path(self, output_path: str) -> None: def delete_by_output_path(self, output_path: str, profile: str = "") -> None:
if not self._enabled: if not self._enabled:
return return
with self._lock: with self._lock:
self._con.execute("DELETE FROM processed WHERE output_path = ?", (output_path,)) if profile:
self._con.execute(
"DELETE FROM processed WHERE output_path = ? AND profile = ?",
(output_path, profile),
)
else:
self._con.execute(
"DELETE FROM processed WHERE output_path = ?", (output_path,),
)
self._con.commit() self._con.commit()
def is_path_used_by_other_profiles(self, output_path: str, profile: str) -> bool:
"""Return True if *output_path* is referenced by any profile other than *profile*."""
if not self._enabled:
return False
row = self._con.execute(
"SELECT 1 FROM processed WHERE output_path = ? AND profile != ? LIMIT 1",
(output_path, profile),
).fetchone()
return row is not None
def get_group(self, output_path: str, profile: str = "") -> list[str]: def get_group(self, output_path: str, profile: str = "") -> list[str]:
"""Return all output_paths sharing the same (filename, start_time, profile) as *output_path*.""" """Return all output_paths sharing the same (filename, start_time, profile) as *output_path*."""
if not self._enabled: if not self._enabled:
@@ -336,29 +425,120 @@ class ProcessedDB:
self._con.commit() self._con.commit()
return paths return paths
def _get_markers_for(self, match: str, profile: str = "default") -> list[tuple[float, int, str]]: def _get_markers_for(self, match: str, profile: str = "default",
rows = self._con.execute( export_folder: str = "") -> list[tuple[float, int, str, float]]:
"SELECT start_time, output_path FROM processed" if export_folder:
" WHERE filename = ? AND profile = ? AND scan_export = 0" rows = self._con.execute(
" ORDER BY start_time", "SELECT start_time, output_path, clip_duration, clip_count, spread"
(match, profile), " FROM processed"
).fetchall() " WHERE filename = ? AND profile = ? AND scan_export = 0"
# Deduplicate by start_time — batch exports share the same cursor. " AND output_path LIKE ?"
seen_times: dict[float, tuple[float, int, str]] = {} " ORDER BY start_time",
n = 0 (match, profile, export_folder.rstrip("/") + "/%"),
for t, p in rows: ).fetchall()
else:
rows = self._con.execute(
"SELECT start_time, output_path, clip_duration, clip_count, spread"
" FROM processed"
" WHERE filename = ? AND profile = ? AND scan_export = 0"
" ORDER BY start_time",
(match, profile),
).fetchall()
seen_times: dict[float, tuple[float, int, str, float]] = {}
seq = 0
for t, p, dur, cnt, spr in rows:
if t not in seen_times: if t not in seen_times:
n += 1 seq += 1
seen_times[t] = (t, n, p) num = _extract_m_number(p) or seq
span = (dur or 8.0) + ((cnt or 1) - 1) * (spr or 3.0)
seen_times[t] = (t, num, p, span)
return list(seen_times.values()) return list(seen_times.values())
def get_markers(self, filename: str, profile: str = "default") -> list[tuple[float, int, str]]: def get_markers(self, filename: str, profile: str = "default",
"""Return [(start_time, marker_number, output_path), ...] for exact export_folder: str = "") -> list[tuple[float, int, str, float]]:
filename match, sorted by start_time. Empty list if no match. """Return [(start_time, marker_number, output_path, clip_span), ...]
Excludes scan exports (shown via scan panel instead).""" for exact filename match, sorted by start_time. Empty list if no match.
Excludes scan exports (shown via scan panel instead).
If export_folder is set, only markers in that folder are returned."""
if not self._enabled: if not self._enabled:
return [] return []
return self._get_markers_for(filename, profile) return self._get_markers_for(filename, profile, export_folder)
def get_other_folder_markers(self, filename: str, profile: str = "default",
export_folder: str = ""
) -> dict[str, list[tuple[float, int, str, float]]]:
"""Return {folder_name: [(start_time, num, path, span), ...]} for
markers NOT in export_folder, grouped by their base export folder."""
if not self._enabled or not export_folder:
return {}
rows = self._con.execute(
"SELECT start_time, output_path, clip_duration, clip_count, spread"
" FROM processed"
" WHERE filename = ? AND profile = ? AND scan_export = 0"
" AND output_path NOT LIKE ?"
" ORDER BY start_time",
(filename, profile, export_folder.rstrip("/") + "/%"),
).fetchall()
by_folder: dict[str, list] = {}
for t, p, dur, cnt, spr in rows:
parts = p.split("/")
for i, part in enumerate(parts):
if part.startswith("vid_"):
folder = "/".join(parts[:i])
break
else:
folder = os.path.dirname(os.path.dirname(p))
by_folder.setdefault(folder, []).append((t, p, dur, cnt, spr))
result: dict[str, list[tuple[float, int, str, float]]] = {}
for folder, folder_rows in by_folder.items():
seen: dict[float, tuple[float, int, str, float]] = {}
seq = 0
for t, p, dur, cnt, spr in folder_rows:
if t not in seen:
seq += 1
num = _extract_m_number(p) or seq
span = (dur or 8.0) + ((cnt or 1) - 1) * (spr or 3.0)
seen[t] = (t, num, p, span)
name = os.path.basename(folder)
if name.endswith("_disabled"):
continue # disabled clips are excluded from the timeline
result[name] = list(seen.values())
return result
def get_manual_export_groups(self, filename: str, profile: str = "default"
) -> list[dict]:
"""Return manual (non-scan) export groups for *filename*.
Each group dict has:
start_time, paths (list[str] sorted), clip_count, clip_duration,
spread, short_side, portrait_ratio, crop_center, format, label,
category
"""
if not self._enabled:
return []
rows = self._con.execute(
"SELECT start_time, output_path, clip_count, clip_duration, spread,"
" short_side, portrait_ratio, crop_center, format, label, category"
" FROM processed"
" WHERE filename = ? AND profile = ? AND scan_export = 0"
" ORDER BY start_time, output_path",
(filename, profile),
).fetchall()
groups: dict[float, dict] = {}
for r in rows:
t = r[0]
if t not in groups:
groups[t] = {
"start_time": t,
"paths": [],
"clip_count": r[2], "clip_duration": r[3],
"spread": r[4],
"short_side": r[5], "portrait_ratio": r[6],
"crop_center": r[7], "format": r[8],
"label": r[9], "category": r[10],
}
groups[t]["paths"].append(r[1])
return list(groups.values())
def get_clip_count(self, filename: str, profile: str = "default") -> int: def get_clip_count(self, filename: str, profile: str = "default") -> int:
"""Return total number of exported clips (including scan exports).""" """Return total number of exported clips (including scan exports)."""
@@ -370,15 +550,254 @@ class ProcessedDB:
).fetchone() ).fetchone()
return row[0] if row else 0 return row[0] if row else 0
def get_clip_counts_by_folder(self, filename: str,
profile: str = "default") -> dict[str, int]:
"""Return per-export-folder clip counts for a single video.
Folder name is the grandparent dir of each clip's output_path
(e.g. ``mp4_doggy_clap``).
"""
if not self._enabled:
return {}
rows = self._con.execute(
"SELECT output_path FROM processed WHERE filename = ? AND profile = ?",
(filename, profile),
).fetchall()
counts: dict[str, int] = {}
for (op,) in rows:
folder = os.path.basename(os.path.dirname(os.path.dirname(op)))
counts[folder] = counts.get(folder, 0) + 1
return counts
def get_clip_counts_grouped(self, profile: str = "default"
) -> dict[str, dict[str, int]]:
"""Return ``{filename: {export_folder: count}}`` for a whole profile
in a single scan (replaces N per-file queries on the hot path)."""
if not self._enabled:
return {}
rows = self._con.execute(
"SELECT filename, output_path FROM processed WHERE profile = ?",
(profile,),
).fetchall()
out: dict[str, dict[str, int]] = {}
for fn, op in rows:
folder = os.path.basename(os.path.dirname(os.path.dirname(op)))
d = out.get(fn)
if d is None:
d = out[fn] = {}
d[folder] = d.get(folder, 0) + 1
return out
def get_all_folder_counts(self, profile: str = "default") -> dict[str, int]:
"""Return clip counts per export folder across all videos in *profile*.
Includes ``_disabled`` folders so callers can offer enable/disable.
"""
if not self._enabled:
return {}
rows = self._con.execute(
"SELECT output_path FROM processed WHERE profile = ?",
(profile,),
).fetchall()
counts: dict[str, int] = {}
for (op,) in rows:
folder = os.path.basename(os.path.dirname(os.path.dirname(op)))
counts[folder] = counts.get(folder, 0) + 1
return counts
def relocate_video_clips(self, filename: "str | None", profile: str,
src_folder_name: str,
dst_folder_name: str) -> int:
"""Move clips from one export folder to a sibling folder.
Matches rows whose grandparent dir basename == *src_folder_name*
(restricted to *filename* when given, else every video in *profile*),
then moves each clip (and any ``.wav`` sidecar) on disk into a sibling
folder named *dst_folder_name*, migrates its dataset.json annotation,
and rewrites output_path in the DB. Returns the number of clips moved.
"""
if not self._enabled:
return 0
import shutil
from .annotations import remove_clip_annotation, upsert_clip_annotation
if filename is None:
rows = self._con.execute(
"SELECT id, output_path, label FROM processed WHERE profile = ?",
(profile,),
).fetchall()
else:
rows = self._con.execute(
"SELECT id, output_path, label FROM processed"
" WHERE filename = ? AND profile = ?",
(filename, profile),
).fetchall()
moves: list[tuple[str, str]] = [] # (old_path, new_path)
updates: list[tuple[str, int]] = [] # (new_path, id)
ann: list[tuple[str, str, str, str, str]] = [] # old_fold,new_fold,old,new,label
new_dirs: set[str] = set()
old_vid_dirs: set[str] = set()
for rid, op, label in rows:
vid_dir = os.path.dirname(op)
export_folder = os.path.dirname(vid_dir)
if os.path.basename(export_folder) != src_folder_name:
continue
new_export_folder = os.path.join(
os.path.dirname(export_folder), dst_folder_name)
new_vid_dir = os.path.join(new_export_folder, os.path.basename(vid_dir))
new_op = os.path.join(new_vid_dir, os.path.basename(op))
updates.append((new_op, rid))
new_dirs.add(new_vid_dir)
old_vid_dirs.add(vid_dir)
if os.path.exists(op):
moves.append((op, new_op))
ann.append((export_folder, new_export_folder, op, new_op, label or ""))
if not updates:
return 0
with self._lock:
for d in sorted(new_dirs):
os.makedirs(d, exist_ok=True)
for old, new in moves:
if os.path.exists(old) and not os.path.exists(new):
shutil.move(old, new)
wav_old, wav_new = old + ".wav", new + ".wav"
if os.path.exists(wav_old) and not os.path.exists(wav_new):
shutil.move(wav_old, wav_new)
self._con.executemany(
"UPDATE processed SET output_path = ? WHERE id = ?", updates)
self._con.commit()
# Migrate dataset.json entries (best-effort, outside the DB lock).
for old_fold, new_fold, old_op, new_op, label in ann:
remove_clip_annotation(old_fold, old_op)
if label:
upsert_clip_annotation(new_fold, new_op, label)
# Remove now-empty old vid dirs and their export folder if empty.
for d in sorted(old_vid_dirs):
try:
if os.path.isdir(d) and not os.listdir(d):
os.rmdir(d)
parent = os.path.dirname(d)
if os.path.isdir(parent) and not os.listdir(parent):
os.rmdir(parent)
except OSError:
pass
_log(f"Relocated {len(updates)} clip(s) of {filename or 'all videos'}: "
f"{src_folder_name} -> {dst_folder_name}")
return len(updates)
def get_profiles(self) -> list[str]: def get_profiles(self) -> list[str]:
"""Return distinct profile names, ordered alphabetically.""" """Return distinct profile names across all tables, ordered alphabetically."""
if not self._enabled: if not self._enabled:
return [] return []
rows = self._con.execute( rows = self._con.execute(
"SELECT DISTINCT profile FROM processed ORDER BY profile" "SELECT DISTINCT profile FROM processed"
" UNION SELECT DISTINCT profile FROM scan_results"
" UNION SELECT DISTINCT profile FROM hard_negatives"
" ORDER BY profile"
).fetchall() ).fetchall()
return [r[0] for r in rows] return [r[0] for r in rows]
def duplicate_profile(self, src: str, dst: str) -> int:
"""Copy all profile data from *src* to *dst*.
Copies processed (exports), scan_results, hard_negatives, and
hidden_files. Returns total number of rows copied.
"""
if not self._enabled or src == dst:
return 0
total = 0
with self._lock:
# processed (exports)
rows = self._con.execute(
"SELECT filename, start_time, output_path, label, category,"
" short_side, portrait_ratio, crop_center, format,"
" clip_count, clip_duration, spread, source_path, scan_export,"
" processed_at"
" FROM processed WHERE profile = ?", (src,),
).fetchall()
for r in rows:
self._con.execute(
"INSERT INTO processed"
" (filename, start_time, output_path, label, category,"
" short_side, portrait_ratio, crop_center, format,"
" clip_count, clip_duration, spread, profile,"
" source_path, scan_export, processed_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(*r[:12], dst, *r[12:]),
)
total += len(rows)
# scan_results
rows = self._con.execute(
"SELECT filename, model, start_time, end_time, score,"
" disabled, orig_start_time, orig_end_time, scan_timestamp"
" FROM scan_results WHERE profile = ?", (src,),
).fetchall()
for r in rows:
self._con.execute(
"INSERT INTO scan_results"
" (filename, profile, model, start_time, end_time, score,"
" disabled, orig_start_time, orig_end_time, scan_timestamp)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(r[0], dst, r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8]),
)
total += len(rows)
# hard_negatives
rows = self._con.execute(
"SELECT filename, start_time, source_path, source_model"
" FROM hard_negatives WHERE profile = ?", (src,),
).fetchall()
for r in rows:
self._con.execute(
"INSERT INTO hard_negatives"
" (filename, profile, start_time, source_path, source_model)"
" VALUES (?, ?, ?, ?, ?)",
(r[0], dst, r[1], r[2], r[3]),
)
total += len(rows)
# hidden_files
rows = self._con.execute(
"SELECT filename FROM hidden_files WHERE profile = ?", (src,),
).fetchall()
for r in rows:
self._con.execute(
"INSERT OR IGNORE INTO hidden_files (filename, profile)"
" VALUES (?, ?)",
(r[0], dst),
)
total += len(rows)
self._con.commit()
return total
def count_profile_rows(self, profile: str) -> int:
"""Return total number of rows across all tables for *profile*."""
if not self._enabled:
return 0
n = 0
for table in ("processed", "scan_results", "hard_negatives", "hidden_files"):
row = self._con.execute(
f"SELECT COUNT(*) FROM {table} WHERE profile = ?", (profile,),
).fetchone()
n += row[0] if row else 0
return n
def delete_profile(self, profile: str) -> None:
"""Delete all rows for *profile* from every table."""
if not self._enabled:
return
with self._lock:
for table in ("processed", "scan_results", "hard_negatives", "hidden_files"):
self._con.execute(
f"DELETE FROM {table} WHERE profile = ?", (profile,),
)
self._con.commit()
def get_all_export_paths(self, profile: str = "default") -> list[str]: def get_all_export_paths(self, profile: str = "default") -> list[str]:
"""Return all unique output_path values for a given profile.""" """Return all unique output_path values for a given profile."""
if not self._enabled: if not self._enabled:
@@ -418,6 +837,32 @@ class ProcessedDB:
pass pass
return max_n return max_n
def get_scan_export_rep_paths_in_range(self, filename: str, profile: str,
start: float, end: float) -> list[str]:
"""Return one representative output_path per distinct scan-export
start_time inside [start, end] for (filename, profile)."""
if not self._enabled:
return []
rows = self._con.execute(
"SELECT output_path FROM processed"
" WHERE filename = ? AND profile = ? AND scan_export = 1"
" AND start_time BETWEEN ? AND ?"
" GROUP BY start_time",
(filename, profile, start, end),
).fetchall()
return [r[0] for r in rows]
def get_scan_export_times(self, filename: str, profile: str) -> list[float]:
"""Return start_times of scan_export=1 rows for this file/profile."""
if not self._enabled:
return []
rows = self._con.execute(
"SELECT start_time FROM processed"
" WHERE filename = ? AND profile = ? AND scan_export = 1",
(filename, profile),
).fetchall()
return [r[0] for r in rows]
def delete_scan_exports(self, filename: str, profile: str) -> int: def delete_scan_exports(self, filename: str, profile: str) -> int:
"""Delete all scan_export entries for *filename* in *profile*. """Delete all scan_export entries for *filename* in *profile*.
@@ -504,13 +949,15 @@ class ProcessedDB:
folder_names: set[str] = set() folder_names: set[str] = set()
for (op,) in rows: for (op,) in rows:
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op))) grandparent = os.path.basename(os.path.dirname(os.path.dirname(op)))
if grandparent: if grandparent and not grandparent.endswith("_disabled"):
folder_names.add(grandparent) folder_names.add(grandparent)
return sorted(folder_names) return sorted(folder_names)
def get_training_data(self, profile: str, positive_folder: str, def get_training_data(self, profile: str,
positive_folder: "str | list[str]",
negative_folder: str = "", negative_folder: str = "",
fallback_video_dir: str = "", fallback_video_dir: str = "",
playlist_paths: list[str] | None = None,
include_scan_exports: bool = False, include_scan_exports: bool = False,
use_hard_negatives: bool = True, use_hard_negatives: bool = True,
) -> list[tuple[str, list[float], list[float], list[float]]]: ) -> list[tuple[str, list[float], list[float], list[float]]]:
@@ -518,18 +965,20 @@ class ProcessedDB:
Args: Args:
profile: profile name profile: profile name
positive_folder: export folder name for positive class (e.g. "mp4_Intense") positive_folder: export folder name(s) for positive class
negative_folder: export folder name for explicit negatives (optional) negative_folder: export folder name for explicit negatives (optional)
fallback_video_dir: if source_path is empty, try filename in this dir fallback_video_dir: if source_path is empty, try filename in this dir
playlist_paths: loaded playlist paths to resolve filenames
include_scan_exports: if True, include auto-exported scan clips include_scan_exports: if True, include auto-exported scan clips
use_hard_negatives: if False, skip hard negatives from scan feedback use_hard_negatives: if False, skip hard negatives from scan feedback
Returns: Returns:
list of (source_video_path, positive_times, soft_times, negative_times) list of (source_video_path, positive_times, soft_times, negative_times)
per video. Soft times = clips from any other non-negative folder. per video. Soft times = clips from any other non-positive/non-negative folder.
""" """
if not self._enabled: if not self._enabled:
return [] return []
pos_folders = {positive_folder} if isinstance(positive_folder, str) else set(positive_folder)
if include_scan_exports: if include_scan_exports:
rows = self._con.execute( rows = self._con.execute(
"SELECT filename, start_time, output_path, source_path" "SELECT filename, start_time, output_path, source_path"
@@ -553,7 +1002,9 @@ class ProcessedDB:
if sp: if sp:
source_by_filename[fn] = sp source_by_filename[fn] = sp
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op))) grandparent = os.path.basename(os.path.dirname(os.path.dirname(op)))
if grandparent == positive_folder: if grandparent.endswith("_disabled"):
continue # disabled clips are excluded from training entirely
if grandparent in pos_folders:
pos_by_video.setdefault(fn, set()).add(st) pos_by_video.setdefault(fn, set()).add(st)
elif negative_folder and grandparent == negative_folder: elif negative_folder and grandparent == negative_folder:
neg_by_video.setdefault(fn, set()).add(st) neg_by_video.setdefault(fn, set()).add(st)
@@ -590,11 +1041,19 @@ class ProcessedDB:
result.append(t) result.append(t)
return result return result
# Build filename→path lookup from playlist
playlist_lookup: dict[str, str] = {}
if playlist_paths:
for p in playlist_paths:
playlist_lookup[os.path.basename(p)] = p
# Include videos that have positives OR explicit negatives # Include videos that have positives OR explicit negatives
all_videos = set(pos_by_video) | set(neg_by_video) all_videos = set(pos_by_video) | set(neg_by_video)
result = [] result = []
for fn in all_videos: for fn in all_videos:
sp = source_by_filename.get(fn, "") sp = source_by_filename.get(fn, "")
if not sp or not os.path.exists(sp):
sp = playlist_lookup.get(fn, "")
if not sp or not os.path.exists(sp): if not sp or not os.path.exists(sp):
if fallback_video_dir: if fallback_video_dir:
sp = os.path.join(fallback_video_dir, fn) sp = os.path.join(fallback_video_dir, fn)
@@ -628,18 +1087,18 @@ class ProcessedDB:
" WHERE profile = ? AND scan_export = 0", " WHERE profile = ? AND scan_export = 0",
(profile,), (profile,),
).fetchall() ).fetchall()
folders = self.get_export_folders(profile, include_scan_exports=include_scan_exports) # Single pass: group by export folder (grandparent dir), counting
stats: dict[str, dict] = {} # clips and distinct source videos. (Was O(folders × rows).)
for folder_name in folders: videos: dict[str, set[str]] = {}
videos: set[str] = set() clips: dict[str, int] = {}
clips = 0 for fn, op in rows:
for fn, op in rows: folder_name = os.path.basename(os.path.dirname(os.path.dirname(op)))
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op))) if not folder_name or folder_name.endswith("_disabled"):
if grandparent == folder_name: continue
videos.add(fn) videos.setdefault(folder_name, set()).add(fn)
clips += 1 clips[folder_name] = clips.get(folder_name, 0) + 1
stats[folder_name] = {"videos": len(videos), "clips": clips} return {f: {"videos": len(videos[f]), "clips": n}
return {k: v for k, v in stats.items() if v["clips"] > 0} for f, n in clips.items() if n > 0}
# ── Scan results ───────────────────────────────────────────── # ── Scan results ─────────────────────────────────────────────
@@ -746,6 +1205,52 @@ class ProcessedDB:
oe if oe is not None else e)) oe if oe is not None else e))
return result return result
def read_scan_bundle(self, filename: str, profile: str):
"""Read (hard_negative_times, scan_export_times, scan_results) for a file.
Uses a fresh short-lived connection so it is safe to call from a worker
thread (WAL allows concurrent readers alongside the main connection).
Returns (set[float], list[float], dict[model -> rows]).
"""
if not self._enabled:
return set(), [], {}
try:
con = sqlite3.connect(self._path)
except sqlite3.Error:
return set(), [], {}
try:
neg = {r[0] for r in con.execute(
"SELECT start_time FROM hard_negatives"
" WHERE filename = ? AND profile = ?",
(filename, profile))}
exported = [r[0] for r in con.execute(
"SELECT start_time FROM processed"
" WHERE filename = ? AND profile = ? AND scan_export = 1",
(filename, profile))]
rows = con.execute(
"SELECT r.id, r.model, r.start_time, r.end_time, r.score,"
" r.disabled, r.orig_start_time, r.orig_end_time"
" FROM scan_results r"
" INNER JOIN ("
" SELECT model, MAX(scan_timestamp) AS latest"
" FROM scan_results"
" WHERE filename = ? AND profile = ?"
" GROUP BY model"
" ) m ON r.model = m.model AND r.scan_timestamp = m.latest"
" WHERE r.filename = ? AND r.profile = ?"
" ORDER BY r.model, r.start_time",
(filename, profile, filename, profile)).fetchall()
results: dict = {}
for row_id, model, s, e, sc, dis, os_, oe in rows:
results.setdefault(model, []).append(
(row_id, s, e, sc, bool(dis),
os_ if os_ is not None else s, oe if oe is not None else e))
return neg, exported, results
except sqlite3.Error:
return set(), [], {}
finally:
con.close()
def delete_scan_result(self, row_id: int) -> None: def delete_scan_result(self, row_id: int) -> None:
"""Delete a single scan result row.""" """Delete a single scan result row."""
if not self._enabled: if not self._enabled:
@@ -777,6 +1282,41 @@ class ProcessedDB:
) )
self._con.commit() self._con.commit()
def insert_scan_result(self, filename: str, profile: str, model: str,
start: float, end: float, score: float,
disabled: bool, orig_start: float, orig_end: float,
scan_timestamp: str = "") -> int:
"""Insert a single scan result row; returns its new id."""
if not self._enabled:
return -1
with self._lock:
cur = self._con.execute(
"INSERT INTO scan_results"
" (filename, profile, model, start_time, end_time, score,"
" disabled, orig_start_time, orig_end_time, scan_timestamp)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(filename, profile, model, start, end, score,
1 if disabled else 0, orig_start, orig_end, scan_timestamp),
)
self._con.commit()
return int(cur.lastrowid or -1)
def update_scan_result_full(self, row_id: int, start: float, end: float,
score: float, orig_start: float,
orig_end: float) -> None:
"""Update bounds, score and orig_* fields — used after merging rows."""
if not self._enabled:
return
with self._lock:
self._con.execute(
"UPDATE scan_results"
" SET start_time = ?, end_time = ?, score = ?,"
" orig_start_time = ?, orig_end_time = ?"
" WHERE id = ?",
(start, end, score, orig_start, orig_end, row_id),
)
self._con.commit()
def get_scan_models(self, filename: str, profile: str) -> list[str]: def get_scan_models(self, filename: str, profile: str) -> list[str]:
"""Return model names that have scan results for this file.""" """Return model names that have scan results for this file."""
if not self._enabled: if not self._enabled:
+223 -3
View File
@@ -78,6 +78,10 @@ def build_ffmpeg_command(
crop_center: float = 0.5, crop_center: float = 0.5,
image_sequence: bool = False, image_sequence: bool = False,
encoder: str = "libx264", encoder: str = "libx264",
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.
@@ -96,7 +100,7 @@ def build_ffmpeg_command(
"-threads", "0", "-threads", "0",
"-ss", str(start), "-ss", str(start),
"-i", input_path, "-i", input_path,
"-t", "8", "-t", str(duration),
] ]
filters: list[str] = [] filters: list[str] = []
@@ -108,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:
@@ -119,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",
@@ -141,20 +158,223 @@ def build_ffmpeg_command(
return cmd return cmd
def build_audio_extract_command(input_path: str, start: float, sequence_dir: str) -> list[str]: def build_audio_extract_command(input_path: str, start: float, sequence_dir: str,
duration: float = 8.0) -> list[str]:
"""Return an ffmpeg command that extracts audio to <sequence_dir>.wav.""" """Return an ffmpeg command that extracts audio to <sequence_dir>.wav."""
audio_path = sequence_dir + ".wav" audio_path = sequence_dir + ".wav"
return [ return [
_bin("ffmpeg"), "-y", _bin("ffmpeg"), "-y",
"-ss", str(start), "-ss", str(start),
"-i", input_path, "-i", input_path,
"-t", "8", "-t", str(duration),
"-vn", "-vn",
"-c:a", "pcm_s16le", "-c:a", "pcm_s16le",
audio_path, audio_path,
] ]
# 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 audio_edit_filters(duration: float, fade_in: float = 0.0,
fade_out: float = 0.0, normalize: bool = False,
gain_db: float = 0.0) -> list[str]:
"""Compose an ffmpeg -af chain for the non-destructive audio edits.
Trim is handled by the caller (it adjusts start/duration), so only
fade / normalize / gain appear here. Returns [] when nothing is set, so
the extract command stays byte-identical to the un-edited case."""
chain: list[str] = []
if fade_in > 0:
chain.append(f"afade=t=in:st=0:d={fade_in}")
if fade_out > 0:
st = round(max(0.0, duration - fade_out), 3)
chain.append(f"afade=t=out:st={st}:d={fade_out}")
if normalize:
# single-pass loudnorm (EBU R128 default target); fine for quick normalize
chain.append("loudnorm")
if gain_db != 0.0:
chain.append(f"volume={gain_db}dB")
return chain
def build_audio_clip_command(input_path: str, start: float, duration: float,
out_path: str,
filters: list[str] | None = None) -> list[str]:
"""ffmpeg command to extract exactly *duration* seconds of audio starting
at *start*, re-encoded per *out_path*'s extension (wav/mp3/flac/…).
*filters* (if any) are joined into a single -af chain."""
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
af = ["-af", ",".join(filters)] if filters else []
return [
_bin("ffmpeg"), "-y",
"-ss", str(start),
"-i", input_path,
"-t", str(duration),
"-vn",
*codec,
*af,
out_path,
]
_ACROSSFADE_CURVES = frozenset({
"tri", "qsin", "hsin", "esin", "log", "ipar", "qua", "cub", "squ", "cbr",
"par", "exp", "iqsin", "ihsin", "dese", "desi", "losi", "sinc", "isinc",
"nofade",
})
def build_crossfade_merge_command(clips: list[str], crossfade,
out_path: str, curves=None) -> list[str]:
"""ffmpeg command concatenating *clips* into *out_path*. *crossfade* is a
single duration (applied to every join) or a per-join list of length
len(clips)-1; 0 = butt-join. *curves* is a single acrossfade curve name or a
per-join list (default 'tri', ffmpeg's default, emitted implicitly)."""
if not clips:
raise ValueError("no clips to merge")
n = len(clips)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
cmd = [_bin("ffmpeg"), "-y"]
for c in clips:
cmd += ["-i", c]
if n == 1:
return cmd + ["-vn", *codec, out_path]
# normalize per-join params (n-1 joins)
xfs = [float(crossfade)] * (n - 1) if isinstance(crossfade, (int, float)) \
else [float(x) for x in crossfade]
if curves is None or isinstance(curves, str):
cvs = [curves or "tri"] * (n - 1)
else:
cvs = [str(c) for c in curves]
if len(xfs) != n - 1:
raise ValueError(f"crossfade list must have {n - 1} entries, got {len(xfs)}")
if len(cvs) != n - 1:
raise ValueError(f"curves list must have {n - 1} entries, got {len(cvs)}")
for cv in cvs:
if cv not in _ACROSSFADE_CURVES:
raise ValueError(f"unknown acrossfade curve: {cv!r}")
# Chain per join: [0][1]->[a1]; [a1][2]->[a2]; …; last label = [out].
# A join uses acrossfade when its duration>0, else per-pair concat=n=2.
# The curve suffix is emitted only for non-'tri' curves ('tri' is ffmpeg's
# default, so omitting it keeps the default command strings byte-identical).
parts, prev = [], "0"
for i in range(1, n):
j = i - 1
label = "out" if i == n - 1 else f"a{i}"
xf = xfs[j] if j < len(xfs) else 0.0
cv = cvs[j] if j < len(cvs) else "tri"
if xf > 0:
suffix = f":c1={cv}:c2={cv}" if cv != "tri" else ""
parts.append(f"[{prev}][{i}]acrossfade=d={round(xf, 3)}{suffix}[{label}]")
else:
parts.append(f"[{prev}][{i}]concat=n=2:v=0:a=1[{label}]")
prev = label
fc = ";".join(parts)
return cmd + ["-filter_complex", fc, "-map", "[out]", *codec, out_path]
def build_audio_delete_command(input_path: str, start: float, end: float,
out_path: str) -> list[str]:
"""Remove [start, end] from the audio: keep [0,start] + [end,inf], concat."""
s, e = round(start, 3), round(end, 3)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]concat=n=2:v=0:a=1[out]")
return [_bin("ffmpeg"), "-y", "-i", input_path,
"-filter_complex", fc, "-map", "[out]", *codec, out_path]
def _auto_heal_crossfade(start: float, end: float,
requested: float | None = None) -> float:
if end <= start:
raise ValueError("heal delete end must be greater than start")
if requested is not None:
fade = max(0.0, float(requested))
else:
fade = min(0.25, max(0.04, (end - start) * 0.25))
# Without knowing total duration, clamp only to available pre-roll.
fade = min(fade, max(0.0, float(start)))
return round(fade, 3)
def build_audio_heal_delete_command(input_path: str, start: float, end: float,
out_path: str,
crossfade: float | None = None) -> list[str]:
"""Remove [start, end] and heal the join with a short equal-power crossfade."""
if end <= start:
raise ValueError("heal delete end must be greater than start")
s, e = round(start, 3), round(end, 3)
xf = _auto_heal_crossfade(s, e, crossfade)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
if xf <= 0:
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]concat=n=2:v=0:a=1[out]")
else:
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]acrossfade=d={xf}:c1=qsin:c2=qsin[out]")
return [_bin("ffmpeg"), "-y", "-i", input_path,
"-filter_complex", fc, "-map", "[out]", *codec, out_path]
def build_audio_silence_command(input_path: str, start: float, end: float,
out_path: str) -> list[str]:
"""Silence the [start, end] region (volume=0 gated by an enable expr)."""
s, e = round(start, 3), round(end, 3)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
# commas inside between() must be escaped so the filtergraph parser doesn't
# treat them as filter separators.
af = f"volume=0:enable='between(t\\,{s}\\,{e})'"
return [_bin("ffmpeg"), "-y", "-i", input_path, "-af", af, *codec, out_path]
def build_audio_reverse_command(input_path: str, start: float, end: float,
out_path: str) -> list[str]:
"""Reverse only the [start, end] segment; head and tail unchanged."""
s, e = round(start, 3), round(end, 3)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={s}:end={e},asetpts=PTS-STARTPTS,areverse[b];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[c];"
f"[a][b][c]concat=n=3:v=0:a=1[out]")
return [_bin("ffmpeg"), "-y", "-i", input_path,
"-filter_complex", fc, "-map", "[out]", *codec, out_path]
def detect_hw_encoders() -> list[str]: def detect_hw_encoders() -> list[str]:
"""Probe ffmpeg for available H.264 hardware encoders. """Probe ffmpeg for available H.264 hardware encoders.
+26
View File
@@ -0,0 +1,26 @@
"""LTX-2 frame-count math. Legal F satisfy F % 8 == 1 (8x temporal + 1)."""
def is_legal_frames(f: int) -> bool:
return f >= 9 and f % 8 == 1
def legal_frames(min_f: int = 9, max_f: int = 1000) -> list[int]:
start = max(9, min_f + ((1 - min_f) % 8)) # first 8k+1 >= min_f
return list(range(start, max_f + 1, 8))
def nearest_legal_frames(f: int) -> int:
if f <= 9:
return 9
low = ((f - 1) // 8) * 8 + 1
high = low + 8
return low if (f - low) <= (high - f) else high
def duration_for_frames(frames: int, fps: float) -> float:
return frames / fps
def frames_for_duration(duration: float, fps: float) -> int:
return nearest_legal_frames(round(duration * fps))
+51
View File
@@ -0,0 +1,51 @@
import subprocess
import numpy as np
from core.paths import _bin
def x_to_t(x: float, width: float, view_start: float, view_dur: float) -> float:
"""Map a pixel x in [0,width] to a time in [view_start, view_start+view_dur]."""
if width <= 0 or view_dur <= 0:
return view_start
return view_start + (x / width) * view_dur
def t_to_x(t: float, width: float, view_start: float, view_dur: float) -> int:
"""Map a time to a pixel x in [0,width] (rounded)."""
if width <= 0 or view_dur <= 0:
return 0
return int(round((t - view_start) / view_dur * width))
def peaks(samples, buckets: int = 128) -> list[float]:
"""Reduce a 1-D sample array to *buckets* normalized peak magnitudes (0..1)."""
if samples is None or len(samples) == 0:
return [0.0] * buckets
a = np.abs(np.asarray(samples, dtype="float32"))
idx = np.linspace(0, len(a), buckets + 1).astype(int)
out = [float(a[idx[i]:idx[i + 1]].max()) if idx[i + 1] > idx[i] else 0.0
for i in range(buckets)]
m = max(out) or 1.0
return [v / m for v in out]
def load_region_samples(path: str, start: float, duration: float,
sr: int = 8000) -> np.ndarray:
"""Decode a [start, start+duration] mono slice via ffmpeg for waveform
preview (low sample rate). Returns a float32 numpy array, empty on failure."""
cmd = [
_bin("ffmpeg"), "-ss", str(start), "-i", path, "-t", str(duration),
"-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le",
"-loglevel", "error", "pipe:1",
]
try:
proc = subprocess.run(cmd, capture_output=True, timeout=60)
except Exception:
return np.zeros(0, dtype="float32")
if proc.returncode != 0 or not proc.stdout:
return np.zeros(0, dtype="float32")
buf = proc.stdout
buf = buf[: len(buf) - (len(buf) % 4)]
return np.frombuffer(buf, dtype="float32")
@@ -0,0 +1,130 @@
# Main Window UI Restructure — Design
**Goal:** Reorganize the `MainWindow` UI in `main.py` from a flat wall of ~50 always-visible controls into a legible, grouped layout — a menu bar for rare actions, a tabbed control deck for settings, an always-visible transport bar, and a real status bar — plus a visual polish pass. Keep every existing behavior, shortcut, and mouse interaction working.
**Scope:** Reorganization **and** visual polish. **Not** an interaction-model change — single-key shortcuts, timeline mouse overloading, and the export/scan logic are untouched.
**Audience:** Single power user. Optimize for density and speed. The goal is *order, not hiding*: keep everything fast to reach; push only genuinely rare actions into menus.
**Runs in:** Python/Qt client (`main.py`), `MainWindow` class only. No `core/` changes.
---
## Problem (from audit)
- **No information architecture.** No menu bar, no toolbar; status bar explicitly disabled (`setStatusBar(None)`, main.py:4440). Every function is a permanently-visible widget at equal weight.
- **`settings_row` overloaded** (main.py:43344370): 24 widgets in one non-wrapping `QHBoxLayout` spanning three unrelated domains (encode/clip params, export variants, audio-scan ML). Needs >1500px; window opens at 1100px.
- **Stranded controls** — e.g. the workers spinbox sits between Cancel and Delete in the transport row (main.py:4316).
- **Weak feedback** — only an 11px `#888` status label at the far-right end of the overflowing settings row (main.py:4364).
- **Flat visual hierarchy** — single Fusion stylesheet, scattered inline `setStyleSheet` state swaps, no primary/secondary distinction, no grouping.
---
## Chosen approach: Tabbed control deck
The 3-pane horizontal splitter (Queue · Center · Scan results) is unchanged. The center column is restructured:
```
╔═ File Edit Scan View Help ═══════════════════ Profile:[default▾] [?] ╗ menu bar (+ corner widgets)
║ ┌Queue──┐ │ current_file.mp4 │ ┌ Scan results ─────┐ ║
║ │+Open │ │ ┌──────────────────────────────────────┐ │ │ [model tabs] │ ║
║ │filter │ │ │ VIDEO (mpv) │ │ │ version▾ │ ║
║ │┌List┬+┐│ │ │ │ │ │ start end score │ ║
║ ││f1 ││ │ │ └──────────────────────────────────────┘ │ │ ... │ ║
║ ││f2 ││ │ │ [════════════ timeline ════════════════] │ │ │ ║
║ │└────┘ ││ │ [════════════ crop bar ════════════════] │ │ [Neg] [Export] │ ║
║ └───────┘ │ ┌─ transport (always visible) ──────────┐ │ └───────────────────┘ ║
║ │ │▶ ⏸ x2 x4 🔒 --/-- ··· [Export] +₁+₂ Cancel Delete│ ║
║ │ ├─[ Export ]─[ Crop & Track ]─[ Scan ]──┤ ← control deck (tabs) ║
║ │ │ (controls for the active tab here) │ ║
║ │ └───────────────────────────────────────┘ ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ Ready. current file · profile: default · 8 wk ║ status bar
╚═══════════════════════════════════════════════════════════════════════════════╝
```
**Why tabbed deck:** Replaces the three stacked rows with a compact tab strip. The transport bar (most-used controls) stays always visible above the tabs; settings group by concern behind tabs. Trade-off accepted: viewing Scan + Export controls simultaneously costs a tab switch.
---
## Control mapping
Every current control has an explicit home; nothing is removed.
### Menu bar (rare / batch / management)
| Menu | Items |
|------|-------|
| **File** | Open Files… · Set export folder… · Quit |
| **Edit** | Undo *(Ctrl+Z → `_scan_panel.undo`)* · Subprofiles ▸ (Add… / Remove…) |
| **Scan** | Scan current · Auto-export · Scan All… · Train classifier… |
| **View** | Review mode ✓ · Subcategory markers ▸ · Hide exported ✓ · Show hidden ✓ |
| **Help** | Keyboard shortcuts *(? / F1)* · What's new · About |
| *corner (right)* | Profile ▾ · `?` |
*Hard Negatives and Dataset Stats remain inside the Train dialog (main.py:682, 762) — not surfaced separately. Profile new/delete remains driven by the profile combo's `activated` handler.*
### Transport bar (always visible — playback + one-press export actions)
`▶ Play · ⏸ Pause · x2 · x4 · 🔒 Lock · --/-- time · ⟨stretch⟩ · next-preview · **Export** · subprofile buttons ₁₂… · Cancel · Delete`
### Control deck — Export tab
`Label · Category · Name · Folder + browse · Format · HW encode · Resize · Duration · Clips · Spread · Workers · Re-export`
### Control deck — Crop & Track tab
`Portrait ratio · 1 random portrait · 1 random square · Track subject`
### Control deck — Scan tab
`Scan model ▾ · ⏲ history · Scan · Auto · Speech · Review · Fuse · Threshold`
### Left pane (Queue) — unchanged
`+ Open · filter · Hide exported · Show hidden · list tabs (tabbed / side-by-side)`
### Right pane (Scan results) — unchanged structurally
### Decisions
- **Train** → Scan menu only (no deck button).
- **Subcategory markers ("Sub")** → View menu submenu (off the deck).
- Items appearing in both a menu and a visible control (Hide exported, Review, Scan, Auto) share one handler and stay synced.
---
## Status bar
Restores `QStatusBar` (removes `setStatusBar(None)`):
- **Left**: transient feedback — `Exporting 2/3…`, `Scan complete · 14 regions`, `Ready.` — with an optional inline `QProgressBar` for export/scan runs. Replaces `_lbl_status` and the `_status_timer` clear logic.
- **Right (permanent widget)**: `current file · profile: <name> · <n> workers`.
---
## Visual polish
Extends the existing dark Fusion theme — no theme change.
1. **Aligned tab layouts** — each deck tab uses `QFormLayout`/grid so `label : control` pairs align in columns (biggest legibility win vs. today's ragged horizontal runs).
2. **Primary/secondary button weight****Export** gets an accent style (blue, reusing `#3a6ea8`); Cancel/Delete read as secondary/destructive. The existing **red Export = "armed to overwrite"** state (main.py:5403) is preserved as a distinct state layered on top.
3. **Consistent toggle states** — x2 / x4 / 🔒 Lock / Review are checkable; one global `:checked` style replaces Lock's ad-hoc inline `#4a3000` swap (main.py:5705).
4. **Spacing rhythm** — uniform margins/spacing; **fixed deck height** (= tallest tab) so the video never resizes on tab switch.
5. **Label cleanup** — de-abbreviate where cheap (`Thr→Threshold`, `Dur→Duration`); replace cryptic `⏲` with a clearer history affordance.
6. **One stylesheet block** — fold scattered inline `setStyleSheet` calls into the central sheet (tabs, separators, status bar, toggles, primary button); keep per-widget overrides only for genuine state changes (overwrite-armed Export).
---
## Implementation notes & risks
- **Preserve all signal wiring.** Controls are re-parented into new layouts, but every existing `connect()` and the controls' object identities are kept — this is a layout move, not a rewrite of handlers.
- **Preserve all shortcuts.** The `QShortcut` block (main.py:44504483) and `_KeyFilter` focus suppression are untouched. Menu items reuse the same handler methods and may display the matching shortcut text.
- **Fixed deck height** prevents video-area jump when switching tabs.
- **Synced menu/button state** — checkable menu items (Review, Hide exported) and their visible toggles must reflect each other; route both through the existing handler and update both widgets.
- **Profile combo** moves to a menu-bar corner widget but keeps its existing `activated` → new/delete/switch logic intact.
- Risk: re-parenting a large `__init__` is error-prone. Mitigate by moving controls in small, independently-runnable stages (menu bar → status bar → deck tabs → transport bar → polish), launching the app after each.
---
## What this does NOT do
- No change to export, scan, tracking, or DB logic — `core/` untouched.
- No change to keyboard shortcuts or timeline mouse interactions.
- No theme change — stays dark Fusion.
- No new features — every control already exists; this is rehousing + polish.
- No change to the Queue or Scan-results panes' internal structure.
@@ -0,0 +1,547 @@
# Main Window UI Restructure — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Re-house `MainWindow`'s ~50 flat controls into a menu bar (rare actions), an always-visible transport bar, a 3-tab control deck (Export / Crop & Track / Scan), and a real status bar — then a visual-polish pass — without changing any behavior, shortcut, or `core/` logic.
**Architecture:** Pure layout reorganization inside `main.py`'s `MainWindow`. Existing widget objects and every `connect()` are **preserved and re-parented**, not recreated. The monster `__init__` is incrementally broken into `_build_*` helper methods (stays single-file — matches the project's architecture). Companion design doc: `docs/plans/2026-06-13-ui-restructure-design.md`.
**Tech Stack:** Python 3.11+, PyQt6, pytest. App entry: `main.py`; launch via `./8cut.sh`.
---
## Conventions for every task
- **Line references drift** as edits land. Always locate by the named symbol (method/variable), not the line number alone. Numbers are the *starting* anchors as of this plan.
- **Authoritative verification is a manual launch.** After each task, run `./8cut.sh`, load a video, and confirm the task's controls work AND prior behavior is intact (play, scrub, export, scan). Use the `verify` skill for structured manual checks.
- **Structure test is the safety net.** `tests/test_ui_structure.py` (built in Task 0.2) constructs `MainWindow` and asserts containment invariants. It **skips gracefully** if construction fails (e.g. no GL for `MpvWidget` in headless CI), so it never blocks `core/` tests. Run with a display: `pytest tests/test_ui_structure.py -v`.
- **Commit after every task.** Small, reversible commits. Commit message convention matches the repo (`feat:`/`fix:`/`refactor:`/`change:`).
- **Do not touch** `core/`, export/scan/tracking logic, the `QShortcut` block (around main.py:44504483), `_KeyFilter`, or `TimelineWidget` mouse handling.
---
## Stage 0 — Branch & safety net
### Task 0.1: Create a working branch
**Step 1:** Confirm clean intent and branch off `master`:
```bash
git switch -c ui-restructure
```
**Step 2:** Verify: `git branch --show-current``ui-restructure`.
(The repo has pre-existing untracked/modified files; leave them alone — they are not part of this work.)
### Task 0.2: Add the structure-test safety net
**Files:**
- Create: `tests/test_ui_structure.py`
**Step 1: Write the test harness + baseline invariant**
```python
import os
import pytest
# A real platform is needed because MpvWidget creates a GL context.
# If construction fails for any environment reason, skip — this test is a
# best-effort structural net, not a gate on core/ tests.
pytestmark = pytest.mark.gui
@pytest.fixture(scope="module")
def app():
from PyQt6.QtWidgets import QApplication
inst = QApplication.instance() or QApplication([])
yield inst
@pytest.fixture
def win(app):
try:
from main import MainWindow
w = MainWindow()
except Exception as e: # GL/mpv/display unavailable, etc.
pytest.skip(f"MainWindow could not be constructed here: {e}")
yield w
w.close()
w.deleteLater()
def _descendant_object_names(widget):
"""All objectNames in a widget's child tree (for containment asserts)."""
return {c.objectName() for c in widget.findChildren(object) if c.objectName()}
def test_window_constructs(win):
assert win.windowTitle() == "8-cut"
```
**Step 2: Run it**
Run: `pytest tests/test_ui_structure.py -v`
Expected: `test_window_constructs` PASSES (with a display) or SKIPS (headless). Either is acceptable — it must not ERROR.
**Step 3:** Register the `gui` marker to silence warnings.
Modify `conftest.py` — append:
```python
def pytest_configure(config):
config.addinivalue_line("markers", "gui: constructs Qt widgets; needs a display")
```
**Step 4: Confirm core tests still pass**
Run: `pytest tests/test_utils.py tests/test_db.py -q`
Expected: PASS (unchanged).
**Step 5: Commit**
```bash
git add tests/test_ui_structure.py conftest.py
git commit -m "test: add MainWindow structure smoke test (skips headless)"
```
---
## Stage 1 — Menu bar
Add a `QMenuBar` whose actions reuse existing handler methods. Move the profile combo and `?` button into menu-bar corner widgets. Keep the original buttons that also live elsewhere (Scan, Auto) — menus and buttons share handlers.
### Task 1.1: Extract a `_build_menubar()` and add the five menus
**Files:**
- Modify: `main.py` `MainWindow.__init__` (call site) and add method `_build_menubar`
**Step 1:** Add the method (place near other `_build`/setup helpers, e.g. after `__init__`). Wire each action to the **existing** handler method:
```python
def _build_menubar(self) -> None:
from PyQt6.QtGui import QAction
mb = self.menuBar()
# File
m_file = mb.addMenu("&File")
m_file.addAction("Open Files…", self._on_open_files)
m_file.addAction("Set export folder…", self._pick_folder)
m_file.addSeparator()
m_file.addAction("Quit", self.close)
# Edit
m_edit = mb.addMenu("&Edit")
self._act_undo = m_edit.addAction("Undo scan edit", self._scan_panel.undo)
self._act_undo.setShortcut("Ctrl+Z")
m_edit.addSeparator()
m_subs = m_edit.addMenu("Subprofiles")
m_subs.addAction("Add…", self._new_subprofile)
self._menu_subprofiles_remove = m_subs.addMenu("Remove")
self._rebuild_remove_subprofile_menu() # built in Task 4.x
# Scan
m_scan = mb.addMenu("&Scan")
m_scan.addAction("Scan current", self._start_scan)
m_scan.addAction("Auto-export", self._auto_export)
m_scan.addSeparator()
m_scan.addAction("Scan All…", self._start_scan_all)
m_scan.addAction("Train classifier…", self._open_train_dialog)
# View
m_view = mb.addMenu("&View")
self._act_review = m_view.addAction("Review mode")
self._act_review.setCheckable(True)
self._act_review.toggled.connect(self._btn_scan_mode.setChecked)
m_view.addAction("Subcategory markers…", self._show_subcat_menu)
m_view.addSeparator()
self._act_hide_exported = m_view.addAction("Hide exported")
self._act_hide_exported.setCheckable(True)
self._act_hide_exported.toggled.connect(self._chk_hide_exported.setChecked)
self._chk_hide_exported.toggled.connect(self._act_hide_exported.setChecked)
self._act_show_hidden = m_view.addAction("Show hidden")
self._act_show_hidden.setCheckable(True)
self._act_show_hidden.toggled.connect(self._btn_show_hidden.setChecked)
self._btn_show_hidden.toggled.connect(self._act_show_hidden.setChecked)
# Help
m_help = mb.addMenu("&Help")
m_help.addAction("Keyboard shortcuts", self._show_shortcuts).setShortcut("F1")
m_help.addAction("What's new", self._show_changelog)
m_help.addAction("About", self._show_about) # tiny method, Task 1.3
```
> **Sync note:** `QAction.toggled`/`QAbstractButton.toggled` do not re-emit when the value is unchanged, so the bidirectional `setChecked` connections (Review, Hide exported, Show hidden) cannot loop. `_btn_scan_mode` → `_act_review` reverse sync is added in Task 3.4 once the button is in the Scan tab.
**Step 2:** Stub the two small new methods referenced above:
```python
def _show_about(self) -> None:
QMessageBox.about(self, "About 8-cut",
f"<b>8-cut</b> v{self.APP_VERSION}<br>"
"8-second clips for foley datasets.")
def _rebuild_remove_subprofile_menu(self) -> None:
self._menu_subprofiles_remove.clear()
for name in self._subprofiles:
self._menu_subprofiles_remove.addAction(
name, lambda _=False, n=name: self._remove_subprofile(n))
self._menu_subprofiles_remove.setEnabled(bool(self._subprofiles))
```
**Step 3:** Call `self._build_menubar()` in `__init__`, **after** `self._scan_panel` and all referenced buttons exist (i.e. just before/after the splitter assembly around main.py:4429). The scan panel is created at main.py:4414, so place the call after that.
**Step 4 (manual verify):** `./8cut.sh` → menu bar shows File/Edit/Scan/View/Help; each item triggers its action; Ctrl+Z still undoes scan edits; F1 shows shortcuts.
**Step 5:** Commit: `feat: add menu bar wired to existing handlers`.
### Task 1.2: Move profile combo + `?` into menu-bar corner
**Files:** Modify `main.py``top_bar` assembly (around main.py:42904294) and `_build_menubar`.
**Step 1:** Remove `self._cmb_profile` and `self._btn_shortcuts` (and the `"Profile:"` `QLabel`) from `top_bar`. Keep `self._lbl_file` in `top_bar` (it stays as the slim filename header above the video).
**Step 2:** In `_build_menubar`, set a corner widget:
```python
from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel
corner = QWidget()
ch = QHBoxLayout(corner)
ch.setContentsMargins(0, 0, 6, 0)
ch.addWidget(QLabel("Profile:"))
ch.addWidget(self._cmb_profile)
ch.addWidget(self._btn_shortcuts)
mb.setCornerWidget(corner, Qt.Corner.TopRightCorner)
```
(Build the corner widget at the end of `_build_menubar`, after `self._cmb_profile` exists — it is created at main.py:4272.)
**Step 3 (manual verify):** Profile dropdown works (switch/new/delete); `?` opens shortcuts; filename still shows above the video.
**Step 4:** Commit: `change: move profile selector and help into menu-bar corner`.
---
## Stage 2 — Status bar
### Task 2.1: Restore `QStatusBar` and route `_show_status` to it
**Files:** Modify `main.py``__init__` (`setStatusBar(None)` at main.py:4440, `_lbl_status`/`_status_timer` at main.py:43644370) and `_show_status` (main.py:5065).
**Step 1:** Replace `self.setStatusBar(None)` with a real status bar built in a helper:
```python
def _build_status_bar(self) -> None:
sb = self.statusBar()
self._status_perm = QLabel("")
self._status_perm.setStyleSheet("color: #888;")
sb.addPermanentWidget(self._status_perm)
self._update_status_perm()
def _update_status_perm(self) -> None:
name = os.path.basename(self._file_path) if self._file_path else ""
self._status_perm.setText(
f"{name} · profile: {self._profile()} · {self._spn_workers.value()} workers")
```
Call `self._build_status_bar()` in `__init__` near the menubar call.
**Step 2:** Rewrite `_show_status` to use the status bar (this subsumes `_status_timer`):
```python
def _show_status(self, msg: str, timeout: int = 0) -> None:
"""Show a transient message in the status bar. timeout in ms (0 = sticky)."""
self.statusBar().showMessage(msg, timeout)
```
**Step 3:** Delete `self._lbl_status`, `self._status_timer`, and `settings_row.addWidget(self._lbl_status)` (main.py:43644370). Remove the `_status_timer.timeout` connection.
**Step 4:** Keep `_update_status_perm()` fresh — call it where file/profile/workers change: end of `_after_load`, in `_on_profile_activated`, and in the `_spn_workers.valueChanged` lambda.
**Step 5 (manual verify):** Start an export → status text appears bottom-left and auto-clears; bottom-right shows file · profile · workers and updates on file/profile/worker change.
**Step 6:** Commit: `feat: real status bar replaces inline status label`.
---
## Stage 3 — Control deck (the core move)
Build a fixed-height `QTabWidget` with three tab pages, then **re-parent** the existing controls from `path_row` and `settings_row` into them. Give each page an `objectName` for the structure test. Do tabs one at a time so the app stays runnable.
### Task 3.1: Build the empty deck and mount it
**Files:** Modify `main.py``right_layout` assembly (main.py:43724382).
**Step 1:** Add a helper that creates the deck and three empty pages:
```python
def _build_control_deck(self) -> "QTabWidget":
from PyQt6.QtWidgets import QTabWidget, QWidget
deck = QTabWidget()
deck.setObjectName("control_deck")
deck.setDocumentMode(True)
self._tab_export = QWidget(); self._tab_export.setObjectName("export_tab")
self._tab_crop = QWidget(); self._tab_crop.setObjectName("crop_tab")
self._tab_scan = QWidget(); self._tab_scan.setObjectName("scan_tab")
deck.addTab(self._tab_export, "Export")
deck.addTab(self._tab_crop, "Crop && Track")
deck.addTab(self._tab_scan, "Scan")
self._control_deck = deck
return deck
```
**Step 2:** In `right_layout`, **keep** `transport_row` for now, but replace the `path_row` and `settings_row` additions with the deck:
- Remove `right_layout.addLayout(path_row)` and `right_layout.addLayout(settings_row)`.
- Add `right_layout.addWidget(self._build_control_deck())`.
- Leave the `path_row`/`settings_row` *construction* in place for this task (the widgets are still parented to nothing visible) — they get moved into tabs in 3.23.4. **App is briefly missing those controls between 3.1 and 3.4; that's expected mid-stage.**
**Step 3 (manual verify):** App launches; three empty tabs appear under the transport bar; switching tabs doesn't resize the video (height fixed in Task 3.5).
**Step 4:** Commit: `refactor: add empty 3-tab control deck under transport`.
### Task 3.2: Populate the Export tab
**Files:** Modify `main.py` — move widgets from `path_row` (main.py:43224331) and the encode/clip parts of `settings_row` (main.py:43344348) plus `_spn_workers` (main.py:4213).
**Step 1:** Build the Export tab with an aligned grid:
```python
def _build_export_tab(self) -> None:
from PyQt6.QtWidgets import QGridLayout, QLabel, QHBoxLayout
g = QGridLayout(self._tab_export)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
# Row 0: annotation
g.addWidget(QLabel("Label:"), 0, 0); g.addWidget(self._txt_label, 0, 1)
g.addWidget(QLabel("Cat:"), 0, 2); g.addWidget(self._cmb_category, 0, 3)
g.addWidget(QLabel("Name:"), 0, 4); g.addWidget(self._txt_name, 0, 5)
# Row 1: output path
folder_row = QHBoxLayout()
folder_row.addWidget(self._txt_folder, 1); folder_row.addWidget(self._btn_folder)
g.addWidget(QLabel("Folder:"), 1, 0); g.addLayout(folder_row, 1, 1, 1, 5)
# Row 2: encode / clip params
g.addWidget(QLabel("Format:"), 2, 0); g.addWidget(self._cmb_format, 2, 1)
g.addWidget(self._chk_hw, 2, 2)
g.addWidget(QLabel("Resize:"), 2, 3); g.addWidget(self._spn_resize, 2, 4)
# Row 3: batch params + actions
g.addWidget(QLabel("Duration:"), 3, 0); g.addWidget(self._spn_clip_dur, 3, 1)
g.addWidget(QLabel("Clips:"), 3, 2); g.addWidget(self._spn_clips, 3, 3)
g.addWidget(QLabel("Spread:"), 3, 4); g.addWidget(self._spn_spread, 3, 5)
g.addWidget(QLabel("Workers:"), 4, 0); g.addWidget(self._spn_workers, 4, 1)
g.addWidget(self._btn_reexport, 4, 5)
```
Call it from `_build_control_deck` (or right after, in `__init__`).
**Step 2:** Delete the now-duplicate `addWidget` calls for these widgets from `path_row` and `settings_row` construction. (Re-parenting via `addWidget` into the grid auto-removes them from the old layout, but remove the dead lines to keep `__init__` honest.)
**Step 3 (manual verify):** Export tab shows aligned Label/Cat/Name, Folder+browse, Format/HW/Resize, Duration/Clips/Spread/Workers/Re-export. Change each → still persists to `QSettings` and updates the timeline span / next-label as before. Export still works (E).
**Step 4:** Commit: `refactor: move export & encode controls into Export tab`.
### Task 3.3: Populate the Crop & Track tab
**Files:** Modify `main.py` — move `_cmb_portrait`, `_chk_rand_portrait`, `_chk_rand_square`, `_chk_track` from `settings_row` (main.py:4337, 43494351).
**Step 1:**
```python
def _build_crop_tab(self) -> None:
from PyQt6.QtWidgets import QGridLayout, QLabel
g = QGridLayout(self._tab_crop)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
g.addWidget(QLabel("Portrait:"), 0, 0); g.addWidget(self._cmb_portrait, 0, 1)
g.addWidget(self._chk_rand_portrait, 1, 0, 1, 2)
g.addWidget(self._chk_rand_square, 2, 0, 1, 2)
g.addWidget(self._chk_track, 3, 0, 1, 2)
g.setRowStretch(4, 1); g.setColumnStretch(2, 1)
```
**Step 2:** Remove those four widgets' old `settings_row.addWidget` lines.
**Step 3 (manual verify):** Crop & Track tab shows the four controls; portrait ratio still toggles the crop overlay/crop-bar; random/track checkboxes persist.
**Step 4:** Commit: `refactor: move crop & track controls into their tab`.
### Task 3.4: Populate the Scan tab (and drop menu-only buttons)
**Files:** Modify `main.py` — move scan widgets from `settings_row` (main.py:43524362). Buttons that became **menu-only** (Train, Scan All, Sub) are NOT added to the tab and are deleted.
**Step 1:**
```python
def _build_scan_tab(self) -> None:
from PyQt6.QtWidgets import QGridLayout, QLabel, QHBoxLayout
g = QGridLayout(self._tab_scan)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
model_row = QHBoxLayout()
model_row.addWidget(self._cmb_scan_model, 1); model_row.addWidget(self._btn_model_history)
g.addWidget(QLabel("Model:"), 0, 0); g.addLayout(model_row, 0, 1, 1, 3)
g.addWidget(self._btn_scan, 1, 0); g.addWidget(self._btn_auto_export, 1, 1)
g.addWidget(self._btn_speech, 1, 2); g.addWidget(self._btn_scan_mode, 1, 3)
g.addWidget(self._spn_auto_fuse, 2, 0); g.addWidget(self._sld_threshold, 2, 1)
g.setColumnStretch(3, 1)
```
**Step 2:** Reverse-sync Review with the View menu (the forward sync was added in Task 1.1):
```python
self._btn_scan_mode.toggled.connect(self._act_review.setChecked)
```
Add this right after `_build_scan_tab` runs (both `_btn_scan_mode` and `_act_review` exist by then).
**Step 3:** Delete the menu-only buttons and their `settings_row` lines: `self._btn_train` (main.py:41674170), `self._btn_scan_all` (main.py:41724174), `self._btn_hide_subcats` (main.py:41544157). Their handlers (`_open_train_dialog`, `_start_scan_all`, `_show_subcat_menu`) stay — now reached via menus.
**Step 4:** Re-anchor `_show_subcat_menu` (main.py:5989) so it no longer depends on the deleted `_btn_hide_subcats`:
```python
# was: self._btn_hide_subcats.mapToGlobal(self._btn_hide_subcats.rect().bottomLeft())
from PyQt6.QtGui import QCursor
menu.exec(QCursor.pos())
```
Apply to **both** `exec` call sites in that method.
**Step 5 (manual verify):** Scan tab shows Model+history, Scan/Auto/Speech/Review, Fuse/Threshold. `Scan` runs; `Review` toggles and stays in sync with View ▸ Review mode (both directions); View ▸ Subcategory markers… opens the full popup near the cursor; Scan ▸ Scan All / Train still work.
**Step 6:** Commit: `refactor: move scan controls into Scan tab; Train/ScanAll/Sub to menus`.
### Task 3.5: Fix deck height; remove dead `path_row`/`settings_row`
**Files:** Modify `main.py``__init__`.
**Step 1:** The `path_row`/`settings_row` `QHBoxLayout`s should now be empty. Delete their construction blocks entirely (main.py:43214370 minus what was already removed), including the `self._transport_row = transport_row` line only if unused elsewhere (it IS used by `_rebuild_subprofile_buttons` — keep `transport_row`).
**Step 2:** Pin the deck height so tab switches don't move the video:
```python
self._control_deck.setFixedHeight(self._control_deck.sizeHint().height())
```
Call after all three tabs are built. If the tallest tab (Export, 5 rows) clips, set an explicit value instead (e.g. `setFixedHeight(150)`); confirm visually.
**Step 3 (manual verify):** Switching Export↔Crop↔Scan keeps the video size constant; no clipped controls; all three tabs fully usable.
**Step 4:** Commit: `refactor: fix control-deck height; drop dead settings rows`.
### Task 3.6: Extend the structure test for the deck
**Files:** Modify `tests/test_ui_structure.py`.
**Step 1:** Add invariants:
```python
def test_menubar_has_expected_menus(win):
titles = [m.title().replace("&", "") for m in win.menuBar().findChildren(type(win.menuBar().addMenu("")))]
for expected in ("File", "Edit", "Scan", "View", "Help"):
assert any(expected == t for t in titles)
def test_status_bar_exists(win):
assert win.statusBar() is not None
def test_workers_spinbox_in_export_tab(win):
from PyQt6.QtWidgets import QSpinBox
assert win._spn_workers in win._tab_export.findChildren(QSpinBox)
def test_scan_button_in_scan_tab(win):
from PyQt6.QtWidgets import QPushButton
assert win._btn_scan in win._tab_scan.findChildren(QPushButton)
def test_portrait_combo_in_crop_tab(win):
from PyQt6.QtWidgets import QComboBox
assert win._cmb_portrait in win._tab_crop.findChildren(QComboBox)
```
(Adjust the menu-title introspection if the helper is awkward; the key invariants are the tab-containment ones.)
**Step 2:** Run: `pytest tests/test_ui_structure.py -v` → PASS with a display (or SKIP headless).
**Step 3:** Commit: `test: assert control-deck containment invariants`.
---
## Stage 4 — Transport bar tidy & subprofile menu sync
### Task 4.1: Confirm transport bar contents; keep subprofile export buttons inline
**Files:** Modify `main.py``transport_row` (main.py:42964319).
**Step 1:** The workers spinbox was moved in Task 3.2 — confirm `transport_row.addWidget(self._spn_workers)` is gone. Remaining transport order: Play, Pause, x2, x4, Lock, time, stretch, next-label, **Export**, subprofile buttons, `+` (add subprofile), Cancel, Delete. Leave subprofile **export** buttons inline (they carry the 19 shortcuts and belong with Export).
**Step 2:** Keep the inline `+` add-subprofile button, but also ensure the Edit ▸ Subprofiles ▸ Remove submenu is rebuilt whenever subprofiles change. In `_rebuild_subprofile_buttons` (main.py:5530-ish) and after add/remove, call `self._rebuild_remove_subprofile_menu()`.
**Step 3 (manual verify):** Transport row reads cleanly; adding/removing a subprofile updates both the inline buttons and Edit ▸ Subprofiles ▸ Remove; number keys 19 still export to subprofiles.
**Step 4:** Commit: `change: tidy transport row; sync subprofile remove menu`.
---
## Stage 5 — Visual polish
All Stage 5 verification is **manual** (visual). Take a screenshot before 5.1 for comparison (use the `run`/`verify` skill).
### Task 5.1: Consolidate the stylesheet (tabs, status bar, toggles, primary button)
**Files:** Modify `main.py` — global stylesheet in `main()` (main.py:38113827).
**Step 1:** Extend the central sheet (append rules; keep existing ones):
```css
QTabWidget::pane { border: 1px solid #444; border-radius: 3px; top: -1px; }
QTabBar::tab { background: #2a2a2a; color: #bbb; padding: 5px 12px;
border: 1px solid #444; border-bottom: none;
border-top-left-radius: 3px; border-top-right-radius: 3px; }
QTabBar::tab:selected { background: #333; color: #fff; }
QPushButton:checked { background: #4a3000; border-color: #ffd230; color: #fff; }
QStatusBar { background: #1a1a1a; color: #bbb; }
QStatusBar::item { border: none; }
QPushButton#primary { background: #3a6ea8; border-color: #4f86c6; color: #fff; }
QPushButton#primary:hover { background: #4f86c6; }
QMenuBar { background: #1e1e1e; } QMenuBar::item:selected { background: #3a6ea8; }
QMenu { background: #2a2a2a; border: 1px solid #555; }
QMenu::item:selected { background: #3a6ea8; }
```
**Step 2:** Mark Export primary: `self._btn_export.setObjectName("primary")`.
**Step 3:** Replace Lock's inline stylesheet swap (main.py:5705) — since `QPushButton:checked` now styles all toggles, delete the two `self._btn_lock.setStyleSheet(...)` lines in `_on_lock_toggled` (keep the rest of the handler).
**Step 4 (manual verify):** Tabs, menus, status bar, and checked toggles (x2/x4/Lock/Review) all read consistently; Export stands out as primary; Lock still highlights when active.
**Step 5:** Commit: `style: unify tab/menu/statusbar/toggle styling; mark Export primary`.
### Task 5.2: Preserve the "armed to overwrite" Export state
**Files:** Inspect `main.py` — the red-Export swaps (main.py:5403, and the resets at 4960/5211/5447/7170/7199/7218).
**Step 1:** These set/clear `self._btn_export.setStyleSheet("QPushButton { background: #6a3030; ... }")` to mean "this export will overwrite". With Export now `objectName("primary")`, an empty `setStyleSheet("")` reset reverts to the **primary** look (good). Confirm the armed (red) state still visually overrides primary — inline stylesheet beats the objectName rule, so it does.
**Step 2 (manual verify):** Select a marker for re-export → Export turns red (armed); deselect → returns to blue primary; export → resets correctly.
**Step 3:** Commit (only if changes were needed): `fix: keep armed-overwrite Export state over primary style`.
### Task 5.3: Label cleanup
**Files:** Modify `main.py` — prefixes/labels.
**Step 1:** De-abbreviate where free: `_sld_threshold.setPrefix("Threshold: ")` (main.py:4207) → keep short if it overflows the tab; `_spn_auto_fuse` prefix stays `"Fuse: "`. Replace the `⏲` history button text with a tooltip-backed `"History"` or a clearer glyph; keep `setFixedWidth` generous enough.
**Step 2 (manual verify):** Labels legible; nothing clipped in the Scan tab.
**Step 3:** Commit: `style: de-abbreviate scan labels`.
---
## Stage 6 — Finalize
### Task 6.1: Full regression pass
**Step 1 (manual, use `verify` skill):** With a real video loaded, confirm end-to-end: scrub/play/pause/speed/lock; export (E) single + batch + subprofile (19); re-export; delete; portrait crop + random + track; scan + auto + speech + review + threshold/fuse; scan-all; train dialog opens; profile switch; queue filter/hide/show-hidden; Ctrl+Z undo; F1/`?` shortcuts.
**Step 2:** Run `pytest -q` (all suites). Expected: `core/` PASS; `test_ui_structure` PASS (display) or SKIP.
### Task 6.2: Docs & changelog
**Files:** Modify `README.md` (UI/shortcuts sections if any references moved) and the in-app `CHANGELOG` list (main.py:4500) — bump `APP_VERSION` and add a "UI restructure" entry so the What's-new dialog announces it.
**Step 1:** Add changelog entry summarizing: menu bar, tabbed control deck, status bar, visual polish; note all shortcuts unchanged.
**Step 2:** Commit: `docs: changelog + README for UI restructure`.
### Task 6.3: Hand off the branch
**Step 1:** `git log --oneline master..ui-restructure` — review the commit series.
**Step 2:** Offer the user: merge to `master`, open a PR, or keep iterating (use `finishing-a-development-branch` skill).
---
## Risk register
| Risk | Mitigation |
|------|-----------|
| Re-parenting breaks a `connect()` | Widgets keep identity; only layout membership changes. Manual launch after every task catches breakage immediately. |
| Headless test can't build `MpvWidget` | Structure test skips on construction failure; manual launch is authoritative. |
| Menu/button state desync (Review, Hide exported) | Bidirectional `setChecked` (no re-emit on equal value → no loop); verified manually in 3.4. |
| Subcat popup anchored to deleted button | Re-anchored to `QCursor.pos()` in Task 3.4. |
| Deck height jump on tab switch | `setFixedHeight` in Task 3.5. |
| Armed-overwrite red Export lost under primary style | Inline stylesheet overrides objectName rule; verified in 5.2. |
| Mid-Stage-3 app missing controls | Expected between 3.13.4; each sub-task is still committable and launchable. |
## What this plan does NOT change
`core/` logic · export/scan/tracking/DB behavior · keyboard shortcuts · timeline mouse interactions · the Queue and Scan-results panes' internals · the dark Fusion theme.
@@ -0,0 +1,96 @@
# Multi-pane Control Deck — Design + Plan Addendum
> Addendum to `2026-06-13-ui-restructure-design.md` / `-implementation.md`. Same branch (`ui-restructure`), same constraints (preserve behavior; reorg/feature only; no `core/` changes).
**Goal:** Let the control-deck panels (Export / Crop & Track / Scan) optionally show **side-by-side as resizable columns** instead of one-at-a-time tabs — mirroring the existing playlist pin→side-by-side pattern.
> **Revision (post-use, 2026-06-13):** The first implementation showed unpinned panels as a "leftover" tab-column so nothing was hidden — but in use, pinning 2 panels then displayed 3 columns, which read as "all three pinned" and was confusing (and inconsistent with what persisted). **Revised behavior:** the split view shows **exactly the pinned panels** as columns (pin 2 → 2 columns, pin 3 → 3). Unpinned panels are not shown as columns. Because the right-click-tab "Show side-by-side" gesture only works in tabbed mode, an always-available **View ▸ Side-by-side panels ▸ Export / Crop / Scan** submenu of checkable toggles is the way to pin/unpin any panel (including adding a 3rd while already in split view). The `if leftovers:` block below is removed; the View submenu + its sync in `_refresh_deck_layout` replace it.
**Mirror these existing playlist members** (study them — the deck is a simpler, fixed-3-panel version): `_PlaylistTabBar` (main.py:3284), `_refresh_layout` (~4872), `_on_pin_toggle`/`_on_unpin` (~4942), `_detach_all_pws`/`_clear_split_container` (~4861), and the `_list_stack`/`_split_container` setup (~39163923).
---
## Design
### Panel identity
The deck's three pages (`_tab_export`, `_tab_crop`, `_tab_scan`) each get three attributes (set in `_build_control_deck`):
- `_pinned: bool = False`
- `_label: str` — "Export" / "Crop & Track" / "Scan"
- `_deck_key: str` — "export" / "crop" / "scan" (stable key for persistence)
Keep an ordered list `self._deck_panels = [self._tab_export, self._tab_crop, self._tab_scan]` for deterministic column order.
### Tab bar
New `class _DeckTabBar(QTabBar)` (minimal version of `_PlaylistTabBar`): on `contextMenuEvent`, show a checkable "Show side-by-side" action reflecting the page's `_pinned`, and emit `pin_toggle_requested(idx)` when chosen. No rename/folder. Install via `self._control_deck.setTabBar(_DeckTabBar())` in `_build_control_deck` and connect `pin_toggle_requested → self._on_deck_pin_toggle`.
### Stacked container (mirrors `_list_stack`)
Wrap the deck so it can swap between tabbed and split views:
- `self._deck_split_container = QWidget()` with an `QHBoxLayout` (`_deck_split_layout`, margins 0, spacing 2).
- `self._deck_stack = QStackedWidget()`; page 0 = `self._control_deck`, page 1 = `self._deck_split_container`.
- In `right_layout`, mount `self._deck_stack` where `self._control_deck` is currently added (replace that one `addWidget`).
### `_refresh_deck_layout()` (mirrors `_refresh_layout`)
```
pinned = [p for p in self._deck_panels if p._pinned]
guard self._deck_loading = True (avoid re-entrant signals)
detach all panels (setParent(None)); self._control_deck.clear(); clear _deck_split_layout
if len(pinned) >= 2:
splitter = QSplitter(Horizontal); splitter.setChildrenCollapsible(False)
leftovers = []
for panel in self._deck_panels: # preserve deck order
if panel._pinned:
col = QWidget(); v = QVBoxLayout(col) (0 margins)
header = label(panel._label, bold) + "✕" button (unpin, fixed 18x18,
tooltip "Return to tabs", clicked → self._on_deck_unpin(panel))
header fixed height ~22
panel.setVisible(True) # reparented pages start hidden
v.addWidget(header); v.addWidget(panel, 1)
splitter.addWidget(col)
else:
leftovers.append(panel)
if leftovers: # keep unpinned reachable as a tab-column
lt = QTabWidget(); lt.setDocumentMode(True)
for panel in leftovers:
panel.setVisible(True); lt.addTab(panel, panel._label)
splitter.addWidget(lt)
splitter.setSizes([1000]*splitter.count())
_deck_split_layout.addWidget(splitter)
self._deck_stack.setCurrentWidget(self._deck_split_container)
else:
for panel in self._deck_panels: # fixed order
self._control_deck.addTab(panel, panel._label)
self._deck_stack.setCurrentWidget(self._control_deck)
restore self._deck_loading
```
### Toggle handlers (mirror `_on_pin_toggle`/`_on_unpin`)
- `_on_deck_pin_toggle(idx)`: `panel = self._control_deck.widget(idx)` (only valid in tabbed mode — pin is only offered there); flip `panel._pinned`; if now pinned and `<2` pinned, `_show_status("Pin another panel to show them side-by-side", 3500)`; `_refresh_deck_layout()`; `_save_deck_layout()`.
- `_on_deck_unpin(panel)`: `panel._pinned = False`; `_refresh_deck_layout()`; `_save_deck_layout()`.
### Persistence
- `_save_deck_layout()`: `self._settings.setValue("deck_pinned", [p._deck_key for p in self._deck_panels if p._pinned])`.
- Restore at the end of `__init__` (after the deck + menubar exist): read `deck_pinned` (handle str/list like the subprofiles loader at main.py:3867), set each panel's `_pinned`, then `_refresh_deck_layout()` once.
### Height
The deck pages now also render with a 22px header in split mode. After building, set the stack's minimum height to fit the tallest **split-mode** column (header + Export content) so split mode never clips: compute once via `self._deck_stack.setMinimumHeight(...)` using `sizeHint`, and keep vertical size policy `Fixed` (as the deck has now). Switching INTO split mode may change the deck height slightly (deliberate user action — acceptable); switching tabs within tabbed mode must still not jump. Reuse the existing height-pin logic — apply it to `_deck_stack` instead of `_control_deck`.
---
## Implementation tasks (bite-sized, commit per task)
**Task M.1 — scaffolding (no behavior change yet).** Add `_DeckTabBar`; in `_build_control_deck` set it on the deck, set `_pinned/_label/_deck_key` on the three pages, build `self._deck_panels`, create `_deck_split_container`/`_deck_split_layout`/`_deck_stack`, and mount `_deck_stack` in `right_layout` instead of `_control_deck`. Connect `pin_toggle_requested` to a stub. App still behaves as plain tabs. Verify: `import main`, structure tests 6/6, and a probe that `_deck_stack.currentWidget() is _control_deck`.
**Task M.2 — split rendering.** Implement `_refresh_deck_layout`, `_detach_deck_panels`, `_clear_deck_split`, `_on_deck_pin_toggle`, `_on_deck_unpin`. Verify with a probe: set two panels `_pinned=True`, call `_refresh_deck_layout()`, assert stack shows `_deck_split_container`, the splitter has 3 columns (2 pinned + 1 leftover QTabWidget), and all three panels are visible/parented; unpin one → back to `_control_deck` with 3 tabs in order.
**Task M.3 — persistence.** Add `_save_deck_layout()` + restore block in `__init__`. Verify a probe round-trips a pinned set through QSettings (use an isolated QSettings scope in the test if needed) without error and that restore calls refresh exactly once.
**Task M.4 — height + tests.** Apply the height-pin to `_deck_stack`; confirm split mode doesn't clip the tallest column. Add structure tests: `test_deck_stack_exists`, and `test_pinning_two_panels_switches_to_split` (programmatically pin 2, refresh, assert `_deck_stack.currentWidget() is _deck_split_container`).
## Verification note
Env quirk (same as the restructure): bare `python -c` constructing `MainWindow` segfaults on mpv GL; run checks under the pytest fixture and `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen`. Visual confirmation (drag dividers, pin/unpin gestures, persistence across real launches) is the user's, done at the end.
## Risks
- **Reparenting hidden pages:** QTabWidget hides non-current pages; reparented panels must be `setVisible(True)` in split columns (same gotcha the playlist documents at main.py:4909-4911).
- **Signal re-entrancy:** guard with `_deck_loading` during refresh.
- **Pin offered in split mode:** `_on_deck_pin_toggle` reads `_control_deck.widget(idx)`, which is only meaningful in tabbed mode. The ✕ header is the unpin path in split mode — don't rely on the context menu there.
- **Height jump on mode toggle:** acceptable (deliberate); tab-switch-within-tabs must remain jump-free.
@@ -0,0 +1,66 @@
# LTX-2 per-tab export mode — Design
**Goal:** Add an export *pipeline mode* to each file-list tab — **Foley** (current behavior) or **LTX-2** — so the same source videos can feed both a Foley dataset (8 s clips) and an LTX-2 V2A dataset (frame-exact, ÷32, 25 fps) without the two ever mixing.
**Depends on:** the per-tab export folder feature (branch `tab-export-folder`) — this design extends that per-tab state. Implementation branch `ltx2-preset` is based on it.
**Scope:** soft preset (no hard enforcement — defaults are LTX-2-legal but every control stays editable). `core/` gains optional pipeline params; Foley path is byte-for-byte unchanged.
---
## LTX-2 constraints (why this exists)
LTX-2 (32× spatial VAE, 8× temporal + 1) requires, for a clip:
- **W and H each divisible by 32.**
- **Frame count F such that `F % 8 == 1`** → 9, 17, 25, … 201, … (transformer seq-len ∝ `(W/32)·(H/32)·((F1)/8+1)`).
- **fps** only sets real duration `F/fps`; for V2A it fixes the paired-audio length and audio↔motion sync, so it must be **consistent across the dataset and equal to the inference `frame_rate`**. Target: **25 fps**.
- V2A video is frozen conditioning → low spatial res (384512) is fine and cheaper.
Note: 8 s @ 25 fps = 200 frames, and `200 % 8 == 0`**8 s is not legal**. Nearest legal: F=193 (7.72 s) or **F=201 (8.04 s)**.
---
## Model: per-tab mode
Each tab (`PlaylistWidget`) gains `_mode ∈ {"foley","ltx2"}`, persisted alongside `_dest_folder`/`_pinned`/`_tab_folder` in `_save_playlist_tabs`/`_load_playlist_tabs`. Default `"foley"` → existing tabs load unchanged. The **active tab's mode drives the export pipeline and the length control.**
### Tab context menu (`_DeckTabBar`/`_PlaylistTabBar`)
- **Duplicate as LTX-2** — headline action: clone the tab's file list + separators into a new tab; set `mode="ltx2"`; derive a separate export folder `"<dest_folder>_ltx2"`; load LTX-2 default geometry. Lets you spin an LTX-2 dataset off a Foley working set.
- **Duplicate tab** — clone keeping the same mode.
- **LTX-2 mode** — checkable, flips an existing tab between foley/ltx2.
- Tab label shows a small **`[LTX2]`** badge when `mode=="ltx2"`.
## What `ltx2` mode changes (soft — still editable)
| Aspect | Foley | LTX-2 |
|--------|-------|-------|
| Clip length | Duration spinbox (seconds) | **Frame-count F** control stepping the legal series (9, 17, …, 201, …); shows `= F/25 s` |
| Output fps | inherits source | **forced 25 fps** (resample; preserves duration/sync) |
| Output W×H | short-side resize → even long side | **center-cropped to ÷32** on both axes (no aspect distortion; loses ≤31 px/side); resize default **512** |
| Frame exactness | duration-based | exactly **F** frames (`-frames:v F`) |
Defaults loaded on convert: resize **512**, **F = 201** (≈8.04 s, mirrors the 8 s Foley clips), ratio as set. All editable afterward.
## Pipeline (`core/ffmpeg.build_ffmpeg_command`)
Add optional params; Foley calls pass none → identical output to today:
- `target_fps: float | None` — when set, append `fps={target_fps}` filter and `-r {target_fps}`.
- `snap32: bool` — when true, after the scale append a centered crop to the nearest lower multiple of 32 on each axis: `crop=trunc(iw/32)*32:trunc(ih/32)*32`.
- Frame-exact length: caller computes `duration = F/target_fps` and passes `-frames:v F` on the video output so the clip has exactly F frames; audio extract uses the same `F/target_fps` duration so V2A pairing stays aligned.
Filter order: portrait-crop (aspect) → scale (short side, ÷32 default) → snap32 crop → fps. The snap32 center-crop runs after scaling so the ÷32 trim is on final pixels.
## UI wiring (`MainWindow`)
- The length spinbox area swaps with the active tab's mode: Foley shows *Duration (s)*; LTX-2 shows *Frames (F)* with a live `= s @25fps` readout. Switching tabs (or toggling mode) reconfigures it; uses the existing `_sync_folder_field_to_tab`-style sync hook on tab change.
- `_on_export` / `_start_export_batch`: when the active tab is `ltx2`, pass `target_fps=25`, `snap32=True`, and frame-exact length to the ffmpeg builder; otherwise unchanged.
- The mismatch guardrail (just added) and per-tab folder continue to apply.
## Persistence & migration
`_mode` added to each tab's saved JSON (default `"foley"` when absent). No DB changes. Existing sessions load every tab as Foley → zero behavior change until a tab is converted.
## What this does NOT do
- No hard enforcement: you can set an illegal F or non-÷32 resize manually; the pipeline still crops to ÷32 and uses whatever F you pick (the *control* defaults/steps keep you legal, but nothing blocks you).
- No motion interpolation on fps resample (frame drop/dup only); keep sources native 25 fps where possible.
- No change to Foley exports, the scan pipeline, or the DB schema.
- No automatic re-export of existing clips into LTX-2 — you cut LTX-2 clips in the converted tab.
@@ -0,0 +1,179 @@
# LTX-2 per-tab export mode — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a per-tab export pipeline mode (Foley | LTX-2) so the same videos can feed both an 8 s Foley dataset and a frame-exact, ÷32, 25 fps LTX-2 V2A dataset, with a "Duplicate as LTX-2" tab action.
**Architecture:** `core/ffmpeg.build_ffmpeg_command` gains optional `target_fps` / `snap32` / `frames` params (Foley path unchanged); a tiny `core/ltx2.py` holds the legal-frame math. `PlaylistWidget` gains `_mode`; the tab menu gains duplicate/convert actions; the length control + `_on_export` wiring switch on the active tab's mode. Soft preset — defaults are legal, everything stays editable.
**Tech Stack:** Python 3.11+, PyQt6, ffmpeg, pytest. Branch `ltx2-preset` (based on `tab-export-folder`). Design: `docs/plans/2026-06-18-ltx2-preset-design.md`.
---
## Conventions
- **Core (`core/ffmpeg.py`, `core/ltx2.py`) is real TDD** — pure functions tested in `tests/test_utils.py` style. Run: `LD_PRELOAD=/usr/lib/libstdc++.so.6 python -m pytest tests/test_utils.py -q` (the preload is needed because importing `main` pulls `mpv`; see `project_qt_test_env`). 3 pre-existing failures there are unrelated — don't count them.
- **GUI parts** verified by the offscreen structure test (`LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v`) plus a **manual launch** (`./8cut.sh`).
- Line numbers are starting anchors; locate by symbol. Commit per task. Co-author trailer on every commit:
`Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
---
## Stage 1 — LTX-2 math (`core/ltx2.py`) [TDD]
### Task 1.1: legal-frame helpers
**Files:** Create `core/ltx2.py`; Test in `tests/test_utils.py` (append).
**Step 1 — failing tests** (append to `tests/test_utils.py`):
```python
from core.ltx2 import is_legal_frames, nearest_legal_frames, frames_for_duration, duration_for_frames, legal_frames
def test_ltx2_is_legal():
assert is_legal_frames(201) and is_legal_frames(9) and is_legal_frames(25)
assert not is_legal_frames(200) and not is_legal_frames(8)
def test_ltx2_nearest():
assert nearest_legal_frames(200) == 201 # 200 -> nearest 8k+1
assert nearest_legal_frames(196) == 193
assert nearest_legal_frames(5) == 9 # floor at 9
def test_ltx2_duration_roundtrip():
assert duration_for_frames(201, 25) == 201 / 25
assert frames_for_duration(8.0, 25) == 201 # 200 -> 201
def test_ltx2_legal_series():
s = legal_frames(min_f=9, max_f=33)
assert s == [9, 17, 25, 33]
```
**Step 2 — run, expect ImportError/FAIL:** `LD_PRELOAD=/usr/lib/libstdc++.so.6 python -m pytest tests/test_utils.py -k ltx2 -q`
**Step 3 — implement `core/ltx2.py`:**
```python
"""LTX-2 frame-count math. Legal F satisfy F % 8 == 1 (8x temporal + 1)."""
def is_legal_frames(f: int) -> bool:
return f >= 9 and f % 8 == 1
def legal_frames(min_f: int = 9, max_f: int = 1000) -> list[int]:
start = max(9, min_f + ((1 - min_f) % 8)) # first 8k+1 >= min_f
return list(range(start, max_f + 1, 8))
def nearest_legal_frames(f: int) -> int:
if f <= 9:
return 9
low = ((f - 1) // 8) * 8 + 1
high = low + 8
return low if (f - low) <= (high - f) else high
def duration_for_frames(frames: int, fps: float) -> float:
return frames / fps
def frames_for_duration(duration: float, fps: float) -> int:
return nearest_legal_frames(round(duration * fps))
```
**Step 4 — run, expect PASS** (same command). **Step 5 — commit:** `feat: LTX-2 legal-frame helpers (core/ltx2.py)`.
---
## Stage 2 — ffmpeg pipeline params [TDD]
### Task 2.1: `target_fps`, `snap32`, `frames` in `build_ffmpeg_command`
**Files:** Modify `core/ffmpeg.py:74` (`build_ffmpeg_command`); Test `tests/test_utils.py`.
**Step 1 — failing tests:**
```python
def test_ffmpeg_ltx2_fps_and_frames():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
short_side=512, target_fps=25, frames=201)
assert "-r" in cmd and cmd[cmd.index("-r")+1] == "25"
assert "-frames:v" in cmd and cmd[cmd.index("-frames:v")+1] == "201"
vf = cmd[cmd.index("-vf")+1]
assert "fps=25" in vf
def test_ffmpeg_ltx2_snap32_crop():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4",
short_side=512, snap32=True)
vf = cmd[cmd.index("-vf")+1]
assert "crop=trunc(iw/32)*32:trunc(ih/32)*32" in vf
def test_ffmpeg_foley_unchanged():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/c.mp4", short_side=256)
assert "-r" not in cmd and "-frames:v" not in cmd
assert "crop=trunc" not in cmd[cmd.index("-vf")+1]
```
**Step 2 — run, expect FAIL** (unexpected kwargs).
**Step 3 — implement:** add params `target_fps: float | None = None, snap32: bool = False, frames: int | None = None` to the signature. After the scale filter (and before the VAAPI block), append:
```python
if snap32:
filters.append("crop=trunc(iw/32)*32:trunc(ih/32)*32")
if target_fps is not None:
filters.append(f"fps={target_fps:g}")
```
Add output flags: after `-t duration` (or near the encoder args, before `output_path`), when `target_fps` set add `cmd += ["-r", f"{target_fps:g}"]`; when `frames` set add `cmd += ["-frames:v", str(frames)]` (video frame cap — exact F). Ensure ordering keeps `-vf` before outputs. Keep `fps`/`snap32` filters out of the `image_sequence=False` vs `True` branches consistently (they apply to both; webp seq also benefits from fps/÷32).
**Step 4 — run, expect PASS.** Also run full `tests/test_utils.py` (the 3 pre-existing failures only). **Step 5 — commit:** `feat: LTX-2 ffmpeg params (target_fps, snap32, frames)`.
### Task 2.2: audio extract honors frame-exact duration
**Files:** `core/ffmpeg.py:145` (`build_audio_extract_command`) — confirm it takes a duration; if it derives from a fixed 8 s, add a `duration` param so the `.wav` for an LTX-2 webp sequence is exactly `F/25 s`. Add a test mirroring `test_audio_extract_timing` asserting the `-t` value equals `frames/fps`. Commit: `fix: audio extract duration for LTX-2 frame-exact clips`.
---
## Stage 3 — per-tab `_mode`
### Task 3.1: attribute + persistence + migration
**Files:** `main.py``PlaylistWidget.__init__` (~3409, next to `_dest_folder`); `_save_playlist_tabs` (~5271); `_load_playlist_tabs` (~5315).
- Add `self._mode: str = "foley"` in `PlaylistWidget.__init__`.
- `_save_playlist_tabs`: add `"mode": pw._mode` to each tab dict.
- `_load_playlist_tabs`: after creating each pw, `pw._mode = t.get("mode", "foley")`.
- `_add_playlist_tab`: new tabs default `_mode="foley"` (already via init).
**Verify:** structure test passes; add `test_tab_mode_defaults_foley` (construct, assert each `_pws[i]._mode == "foley"`). Commit: `feat: per-tab export mode attribute (foley default)`.
---
## Stage 4 — tab menu: duplicate / convert / toggle
### Task 4.1: menu actions + label badge
**Files:** `main.py``_PlaylistTabBar.contextMenuEvent` (~3300) add items; new handlers in `MainWindow`; tab-title rendering.
- Add to the tab context menu: **"Duplicate tab"**, **"Duplicate as LTX-2"**, and a checkable **"LTX-2 mode"** (checked when `pw._mode=="ltx2"`). Emit new signals (e.g. `duplicate_requested(idx, as_ltx2: bool)`, `mode_toggle_requested(idx)`) like the existing `pin_toggle_requested`.
- `MainWindow._on_duplicate_tab(idx, as_ltx2)`: build a new tab via `_add_playlist_tab(label=…, files=list(src._paths), separators=sorted(src._separators_before), select=True)`; set `pw._dest_folder = src._dest_folder + ("_ltx2" if as_ltx2 else "")`; `pw._mode = "ltx2" if as_ltx2 else src._mode`; if ltx2, apply LTX-2 defaults (Stage 5 hook); `_save_playlist_tabs()`; refresh.
- `MainWindow._on_tab_mode_toggle(idx)`: flip `pw._mode`; if now ltx2, apply LTX-2 defaults; `_save_playlist_tabs()`; re-sync controls (Stage 5).
- Label badge: when adding/refreshing a tab whose `_mode=="ltx2"`, show `f"{label} [LTX2]"` (or set a distinct color) — apply in `_refresh_layout`/`_add_playlist_tab` title set.
**Verify:** manual launch — right-click a tab → Duplicate as LTX-2 creates a `[LTX2]` tab with `_ltx2` folder; toggle works. Structure test still green. Commit: `feat: tab duplicate / Duplicate-as-LTX-2 / mode toggle + [LTX2] badge`.
---
## Stage 5 — length control swap + export wiring
### Task 5.1: length control reflects active tab mode
**Files:** `main.py` — the clip-length widgets (`_spn_clip_dur` ~4051 area) + the tab-change sync hook (`_on_tab_changed` / `_sync_folder_field_to_tab` neighbor).
- Add a frames spinbox `_spn_frames` (min 9, singleStep 8 → always 8k+1; suffix " f"; tooltip live `= F/25 s`). Default 201.
- Add `_apply_mode_to_controls()`: if active tab `ltx2` → show `_spn_frames` (+ "Frames" label), hide the seconds Duration control, default resize 512 if unset; else show Duration (seconds), hide frames. Call it from `_on_tab_changed`, after `_on_duplicate_tab`/`_on_tab_mode_toggle`, and once after `_load_playlist_tabs`.
- A small label shows `= {F/25:.2f}s @25fps` updating on `_spn_frames.valueChanged`.
### Task 5.2: route LTX-2 params through export
**Files:** `main.py``_on_export` (~7317) + `ExportWorker` construction (~7484) + `_update_next_label`.
- When the active tab's `_mode=="ltx2"`: compute `frames = self._spn_frames.value()`; `fps = 25`; `duration = frames / fps`; pass `target_fps=25, snap32=True, frames=frames, duration=duration` through to `ExportWorker``build_ffmpeg_command`. Default `short_side` to 512 if 0/None in ltx2.
- Foley path: unchanged (no new params).
- `ExportWorker.__init__`/`run`: thread the new params (default None/False) into `build_ffmpeg_command`.
**Verify (manual, authoritative):** in an LTX-2 tab, export → inspect an output clip: `ffprobe` shows **25 fps, exactly F frames, W&H ÷32**; a Foley tab still exports 8 s/source-fps unchanged. Structure test green; full `pytest tests/test_utils.py` (3 pre-existing fails only). Commit: `feat: route LTX-2 (25fps, ÷32 crop, F frames) through export for ltx2 tabs`.
---
## Stage 6 — finalize
- **Task 6.1:** Full regression — `pytest tests/test_ui_structure.py` + `tests/test_utils.py` separately; manual: Foley export unchanged, LTX-2 export legal (ffprobe), duplicate/convert, persistence across relaunch, guardrail + per-tab folder still work.
- **Task 6.2:** Changelog (`main.py` CHANGELOG, bump APP_VERSION) + README note (per-tab LTX-2 mode). Commit `docs: changelog + README for LTX-2 export mode`.
- **Task 6.3:** Hand off branch (depends on `tab-export-folder`; merge that first, then this).
## Risks
| Risk | Mitigation |
|------|-----------|
| `-frames:v` vs `-t` interaction yields F±1 frames | Set both `-t F/fps` and `-frames:v F`; verify exact count with ffprobe in 5.2. |
| `fps` filter + HW (VAAPI) filter ordering | Place `fps`/`snap32` among CPU filters before the VAAPI hwupload block; test a HW-encoder build if available. |
| Length-control swap leaves stale state across tab switches | `_apply_mode_to_controls()` called on every tab change + mode toggle + load. |
| Depends on unmerged `tab-export-folder` | Branch is based on it; land that branch first. |
## NOT in scope
Hard enforcement (illegal F/resize allowed manually), motion-interpolated fps, auto re-export of existing Foley clips, DB schema changes, scan-pipeline changes.
@@ -0,0 +1,187 @@
# Phase 3a — Interactive waveform selection — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Turn the read-only `AudioWaveform` strip into an interactive selection surface — drag in/out handles + click-drag to select + wheel zoom — whose selection drives the extract region (`_cursor` + `_spn_audio_len`), two-way synced with the length spinbox.
**Architecture:** Pure pixel↔time geometry in `core/waveform.py` (TDD); interactive state + painting + mouse/wheel on `AudioWaveform` (main.py); MainWindow wires the widget's `selection_changed`/`view_changed` to `_cursor`/`_spn_audio_len` and peak re-decode. Extract/audition/add-to-merge are unchanged (they already read `_cursor`+length).
**Design doc:** `docs/plans/2026-07-02-audio-editor-fancier-design.md`
**Branch:** `audio-tab`. UI tests: `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v`. Never combine test_ui_structure.py + test_utils.py (segfault). 3 known pre-existing test_utils.py failures — leave them.
---
## Task W1: Pure pixel↔time geometry (`core/waveform.py`, TDD)
**Files:** Modify `core/waveform.py`; test `tests/test_utils.py`.
**Step 1 — failing tests:**
```python
def test_time_pixel_roundtrip():
from core.waveform import t_to_x, x_to_t
# window [10s, 10s+4s] across 400px
assert x_to_t(0, 400, 10.0, 4.0) == 10.0
assert x_to_t(400, 400, 10.0, 4.0) == 14.0
assert x_to_t(200, 400, 10.0, 4.0) == 12.0
assert t_to_x(12.0, 400, 10.0, 4.0) == 200
# round-trip
for x in (0, 37, 200, 399):
assert abs(t_to_x(x_to_t(x, 400, 10.0, 4.0), 400, 10.0, 4.0) - x) <= 1
def test_time_pixel_guards():
from core.waveform import t_to_x, x_to_t
assert x_to_t(50, 0, 10.0, 4.0) == 10.0 # zero width -> view_start
assert x_to_t(50, 400, 10.0, 0.0) == 10.0 # zero span -> view_start
assert t_to_x(12.0, 400, 10.0, 0.0) == 0 # zero span -> 0
```
**Step 2** — run `...pytest tests/test_utils.py -k "time_pixel" -v` → FAIL.
**Step 3 — implement** in `core/waveform.py`:
```python
def x_to_t(x: float, width: float, view_start: float, view_dur: float) -> float:
"""Map a pixel x in [0,width] to a time in [view_start, view_start+view_dur]."""
if width <= 0 or view_dur <= 0:
return view_start
return view_start + (x / width) * view_dur
def t_to_x(t: float, width: float, view_start: float, view_dur: float) -> int:
"""Map a time to a pixel x in [0,width] (rounded)."""
if width <= 0 or view_dur <= 0:
return 0
return int(round((t - view_start) / view_dur * width))
```
**Step 4**`...pytest tests/test_utils.py -k "time_pixel or peaks or load_region" -v` → PASS.
**Step 5 — commit:** `feat: pixel<->time geometry helpers for the waveform`
---
## Task W2: AudioWaveform gains view + selection state + painting
**Files:** Modify `main.py` (`AudioWaveform`); test `tests/test_ui_structure.py`.
**Step 1 — failing test:**
```python
def test_waveform_view_and_selection(win):
w = win._wave
w.set_view(10.0, 4.0)
w.set_selection(11.0, 12.5)
assert w._view_start == 10.0 and w._view_dur == 4.0
assert w.selection() == (11.0, 12.5)
# selection clamps into the view window
w.set_selection(9.0, 20.0)
s, e = w.selection()
assert s >= 10.0 and e <= 14.0 and s < e
w.set_peaks([0.1, 0.9, 0.3]) # still paints without crashing
```
**Step 2** — run → FAIL.
**Step 3 — implement** on `AudioWaveform` (extend, keep `set_peaks`/`clear`/read-only fallback):
- `__init__`: add `self._view_start = 0.0`, `self._view_dur = 0.0`, `self._sel = None # (start,end) | None`, `self._playhead = None`; `setFixedHeight(96)`; keep tooltip.
- `set_view(start, dur)`: store, `update()`.
- `set_selection(start, end)`: clamp to `[view_start, view_start+view_dur]`, enforce `start<end`; store in `self._sel`; `update()`.
- `selection() -> tuple|None`: return `self._sel`.
- `set_playhead(t|None)`: store, `update()`.
- `paintEvent`: keep peak bars; if `self._sel` and `_view_dur>0`, paint a translucent band (`QColor(0,200,180,50)`) between `t_to_x(sel_start)` and `t_to_x(sel_end)`, and two 2px handle lines (`QColor(0,220,190)`) at those x. If `_playhead` in-window, a 1px vertical `QColor(255,255,255,160)` line. Use `from core.waveform import t_to_x` (function-local import per file style).
**Step 4** — full `tests/test_ui_structure.py` → new test + prior 37 pass.
**Step 5 — commit:** `feat: waveform view/selection state + band/handle painting`
---
## Task W3: Mouse drag + wheel zoom → signals
**Files:** Modify `main.py` (`AudioWaveform`); test `tests/test_ui_structure.py`.
**Step 1 — failing test** (drive the handlers directly with synthetic positions rather than real Qt events where possible):
```python
def test_waveform_drag_emits_selection(win):
from PyQt6.QtCore import QPointF
w = win._wave
w.resize(400, 96)
w.set_view(10.0, 4.0)
w.set_selection(11.0, 13.0)
got = []
w.selection_changed.connect(lambda s, e: got.append((s, e)))
# simulate: press in empty area near t=10.5 -> new selection anchor,
# drag to t=13.5, release
w._begin_drag_at_x(w._t_to_px(10.5))
w._drag_to_x(w._t_to_px(13.5))
w._end_drag()
assert got, "selection_changed should fire on release"
s, e = w.selection()
assert 10.0 <= s < e <= 14.0
def test_waveform_zoom_emits_view(win):
w = win._wave
w.resize(400, 96)
w.set_view(10.0, 4.0)
seen = []
w.view_changed.connect(lambda s, d: seen.append((s, d)))
w._zoom_at_x(200, 0.5) # zoom in (factor<1) about the middle
assert seen and seen[-1][1] < 4.0 # span shrank
```
**Step 2** — run → FAIL.
**Step 3 — implement:**
- Add signals: `selection_changed = pyqtSignal(float, float)`, `view_changed = pyqtSignal(float, float)`.
- Helper `_t_to_px(t)` / `_px_to_t(x)` wrapping the core helpers with `self.width()`, `self._view_start`, `self._view_dur`.
- Refactor the drag into small methods the test can call: `_begin_drag_at_x(x)` (pick handle if within `HANDLE_PX`, else start new selection anchored at that time), `_drag_to_x(x)` (move active edge, clamp, `update()`), `_end_drag()` (finalize + `emit selection_changed`). `mousePressEvent`/`mouseMoveEvent`/`mouseReleaseEvent` delegate to these using `event.position().x()`.
- `_zoom_at_x(x, factor)`: `new_dur = clamp(self._view_dur*factor, MIN_SPAN, source-cap)`, keep the time under the pointer fixed, set `_view_start`/`_view_dur`, `update()`, `emit view_changed`. `wheelEvent` calls it with `factor=0.8`(in)/`1.25`(out) using the wheel delta sign.
**Step 4** — full UI file → new tests + prior pass.
**Step 5 — commit:** `feat: waveform drag-select + wheel zoom (selection_changed/view_changed)`
---
## Task W4: MainWindow wiring (two-way sync + re-decode)
**Files:** Modify `main.py`; test `tests/test_ui_structure.py`.
**Step 1 — failing test:**
```python
def test_waveform_selection_syncs_cursor_and_length(win):
win._file_path = "/x/video.mp4"
win._cursor = 10.0
win._spn_audio_len.setValue(3.0)
win._wave.set_view(10.0, 6.0)
win._wave.set_selection(11.0, 14.0)
win._on_wave_selection_changed(11.0, 14.0)
assert win._cursor == 11.0
assert abs(win._spn_audio_len.value() - 3.0) < 1e-6
```
**Step 2** — run → FAIL.
**Step 3 — implement:**
- Connect in `__init__` (where `_wave` is built): `self._wave.selection_changed.connect(self._on_wave_selection_changed)` and `self._wave.view_changed.connect(self._on_wave_view_changed)`.
- `_on_wave_selection_changed(s, e)`: set `self._cursor = s`; set the length spinbox to `e-s` (block its signal or let it update the band); call `_update_audio_region()` and seek the video to `s` if desired (optional). Guard a re-entrancy flag so programmatic selection sets don't loop.
- `_on_wave_view_changed(vs, vd)`: re-decode peaks for `[vs, vd]` via the existing capped `load_region_samples`+`peaks` (reuse `_on_wave_refresh`'s decode, factored into a small `_decode_wave(vs, vd)` helper) and `set_peaks`.
- Update `_on_wave_refresh` to also `set_view(self._cursor, view_dur)` and `set_selection(self._cursor, self._cursor+len)` (view_dur = max(len, MIN_VIEW)). Two-way: when `_spn_audio_len` changes by typing, update the selection to `[_cursor, _cursor+len]` (add to `_on_audio_len_changed`, guarded).
**Step 4** — full UI file → new test + prior pass; manually confirm no signal feedback loop (the re-entrancy guard).
**Step 5 — commit:** `feat: wire interactive waveform selection to cursor/length (two-way)`
---
## Task W5: Playhead during audition + verify + docs
**Files:** `main.py`, `README.md`; verify.
- **Playhead:** on audition start, start a `QTimer` (~50ms) setting `self._wave.set_playhead(sel_start + elapsed)`; stop + `set_playhead(None)` in `_teardown_audition`/`_stop_audition`. Elapsed via a stored start time (use a QElapsedTimer, not `time`/`Date`).
- **Docs:** README "Audio extraction & editing" gains an interactive-waveform bullet; changelog bump to a 1.5 entry ("Interactive waveform — drag to select the clip, zoom, playhead").
- **Verify:** each test file separately (expect 37+new passes; the 3 known failures only); import smoke.
- **Commit:** `feat: waveform playhead during audition + docs (v1.5)`
---
## Deferred to 3b / 3c
- Per-join crossfade + curves (3b); destructive clip editor (3c) — separate plans after 3a lands.
@@ -0,0 +1,72 @@
# Audio editor — "fancier" features (Phase 3) — Design
**Goal:** Three deferred editing capabilities on the Audio tab, phased:
- **3a — Interactive waveform selection** (foundation): drag in/out handles, zoom, moving playhead.
- **3b — Per-join crossfade + curves** in the Merge pane.
- **3c — Destructive clip editor**: cut/delete/silence/reverse a region with undo/redo.
**Builds on:** Phase 1/2 — `AudioWaveform` (read-only strip), `load_region_samples`/`peaks`, extract/audition via `_current_edit_filters`, the Merge pane + `build_crossfade_merge_command`.
**Runs in:** Python/Qt client + `core/`. No server/DB change.
**Order:** 3a first (unlocks precise selection everywhere), then 3b (small engine extension), then 3c (largest; reuses 3a's widget). Same `audio-tab` branch, reviewed per task.
---
## Phase 3a — Interactive waveform selection
Turn `AudioWaveform` into a selection surface that drives the extract region. Extract/audition/add-to-merge already use `_cursor` + `_spn_audio_len`, so they honor the dragged selection with no handler change.
**View + selection model (all seconds, absolute in source):**
- `_view_start`, `_view_dur` — the decoded, zoomable visible window (peaks are for this window, via the existing capped `load_region_samples`).
- `_sel_start`, `_sel_end` — the selection within the window.
- Pixel↔time helpers (pure, unit-tested): `t = _view_start + (x/width)·_view_dur`; `x = (t_view_start)/_view_dur·width`.
**Interaction:**
- Press within `HANDLE_PX` of an in/out handle → drag that edge; press elsewhere → start a new selection at that time, drag to extend.
- Move → update the active edge, clamp to the window, keep `start < end`, repaint.
- Release → emit `selection_changed(sel_start, sel_end)`.
- Wheel → zoom `_view_dur` about the pointer time (clamped to a min span and source length) → emit `view_changed(view_start, view_dur)`.
- Height grows 48→~96 for usable dragging.
**Painting (extends current):** peak bars + a translucent selection band + two bright handle lines + an optional playhead line.
**Signals:** `selection_changed(float,float)`, `view_changed(float,float)`.
**MainWindow wiring:**
- Refresh (↻ / on load): decode `[_cursor, _cursor+view_dur]` (view_dur defaults to max(length, a few s)); `set_view(...)`, `set_peaks(...)`, selection = `[_cursor, _cursor+len]`.
- `selection_changed` → set `_cursor = sel_start` and `_spn_audio_len = sel_endsel_start` (which already refreshes the timeline band); block signals while syncing to avoid feedback.
- Typing `_spn_audio_len` → update the waveform selection (two-way sync).
- `view_changed` → re-decode peaks for the new window (throttled, capped decode).
**Playhead (optional within 3a):** during audition a QTimer sets `_playhead = sel_start + elapsed` (ffplay gives no position; elapsed-based is close enough), cleared on stop. Deferrable if it complicates.
**Tests:** unit-test the pixel↔time round-trip + clamping (pure); smoke-test that a simulated drag emits `selection_changed` and syncs `_cursor`/length, and that typing a length moves the selection.
---
## Phase 3b — Per-join crossfade + curves
- Merge items gain a per-join crossfade + curve (stored `UserRole+2` = float, `UserRole+3` = curve name). New joins default from the global `_spn_crossfade` + a new curve combo; double-click a row to override that join.
- `build_crossfade_merge_command` extended: accept `crossfades: float | list[float]` (scalar broadcasts to every join — backward compatible) and `curve: str` (acrossfade `c1=/c2=`; e.g. `tri`/`exp`/`log`/`qsin`). Join *i*`acrossfade=d={xf[i]}:c1={curve}:c2={curve}`.
- The duration guard checks each clip against its own join's crossfade.
---
## Phase 3c — Destructive clip editor (largest)
A modal editor over ONE file (the current selection rendered to a temp, a merge output, or an opened file). Detailed plan authored after 3a/3b land.
- Full-file interactive waveform (reuse the 3a widget) + a selection.
- Region ops, each rendering a NEW temp version (non-in-place → clean undo):
- **Delete** — `atrim`+`concat` of `[0,selStart]`+`[selEnd,end]`.
- **Silence** — `volume=0`/replace over the region.
- **Reverse** — `areverse` (segment via `atrim`+`areverse`+`concat`).
- **Trim to selection** — keep only `[selStart,selEnd]`.
- **Fade selection** — `afade` on the region.
- **Undo/redo:** a stack of rendered version files; undo pops to the prior file, redo re-applies. Robust and simple.
- **Save / Save-as.** Pure per-op command builders in `core/ffmpeg.py` (TDD); the dialog wires them.
---
## What this does NOT do
- No multi-track timeline / overlap mixing; no spectral editing; no saved editor projects (session/file-based only).
+118
View File
@@ -0,0 +1,118 @@
# Audio merge (crossfade) — Phase 2 design
**Goal:** Let the user **easily assemble multiple audio clips into one output with
crossfades at the joins** — directly from the Audio tab — with room to grow into
fancier assembly (per-join curves, gain automation, effects). This is the
"audio editor" the user asked for; its north star is *crossfade merging*.
**Runs in:** Python/Qt client (`main.py`) + `core/ffmpeg.py`. No server/DB change.
**Builds on:** Phase 1 (the Audio QToolBox tab, `build_audio_clip_command`,
`audio_edit_filters`, the waveform strip, and ffplay audition).
---
## Priority / ordering
The stated goal is crossfade merging, so that lands first. The interactive
drag-select waveform (approved separately) is a *precision clip-picker* that
feeds the merge — valuable but not required for a working merge (whole
clips/files merge fine). Ordering:
- **Phase 2 (this doc):** the **Merge pane** + crossfade engine. Clips are added
from the current extract selection (rendered with its edits) or from files on
disk.
- **Phase 2-later:** interactive waveform selection (drag in/out handles, zoom,
moving playhead) as the way to define each added clip precisely; then "fancier
stuff" (per-join crossfade curves, per-clip gain, reorder-by-drag).
---
## Engine — `core/ffmpeg.py`
`build_crossfade_merge_command(clips: list[str], crossfade: float, out_path: str) -> list[str]`
- **0 clips** → `ValueError` (caller guards; never invoked empty).
- **1 clip** → straight re-encode to `out_path`'s format: `-i c0 -vn <codec> out`.
- **≥2 clips, crossfade > 0** → chain ffmpeg `acrossfade` (available; `N->A`):
```
-i c0 -i c1 ... -i c{n-1}
-filter_complex
"[0][1]acrossfade=d=D[a1];[a1][2]acrossfade=d=D[a2];…;[a{n-2}][{n-1}]acrossfade=d=D[out]"
-map "[out]" <codec> out
```
(single global crossfade `D` on every join in v1; per-join comes later).
For exactly 2 clips the label is `[out]` directly (no intermediate).
- **≥2 clips, crossfade == 0** → butt-join via the `concat` filter
(`concat=n=N:v=0:a=1`) so a zero-crossfade merge still works.
Codec chosen by `out_path` extension via the existing `_AUDIO_CODEC_BY_EXT`
(reuse — same formats as extract: wav/mp3/flac/m4a/ogg/opus).
Note: acrossfade needs each pair to share a sample format; ffmpeg auto-negotiates
via the filtergraph, but if mixed-rate inputs cause trouble we insert `aresample`
before each input. Start without it; add only if a real mismatch shows up.
Pure function → fully unit-tested (no ffmpeg run needed).
---
## UI — a third QToolBox pane: **Merge**
Added to `_build_audio_tab` after "Extract & Edit" and "Scan / Classify".
**Widgets (constructed in `__init__`):**
- `_merge_list` — a `QListWidget` (reorderable) of clips to merge, in order. Each
row shows the basename + duration (probed once on add). The clip's absolute
path is stored on the item (`Qt.UserRole`).
- `_spn_crossfade` — `QDoubleSpinBox`, 0.010.0 s, step 0.1, default 0.5 s, suffix
" s". One global crossfade applied to every join in v1.
- Buttons: ** Selection** (add the current extract area, rendered with the edit
chain, to a temp clip and append), ** File…** (`QFileDialog` multi-select audio
files), **▲ / ▼** (reorder selected row), **✕** (remove selected),
**▶ Preview** (merge to a temp file and audition via the Phase-1 ffplay path),
**Merge & Save…** (render + save-as).
**Handlers:**
- ** Selection** — reuse `build_audio_clip_command(self._file_path, cursor, len,
tmp, filters=self._current_edit_filters() or None)` → temp wav in the app temp
dir (unique name per add) → append to `_merge_list`. Guards on a loaded file.
- ** File…** — append each chosen path.
- **▲/▼/✕** — list reorder/remove.
- **Merge & Save…** (`_on_merge_save`) — collect the ordered paths; if <1 clip,
status + return; build via `build_crossfade_merge_command(paths,
_spn_crossfade.value(), out)`; `QFileDialog.getSaveFileName` (same format
filter as extract, remembered dir); `subprocess.run` under a wait cursor +
status; report saved length via `probe_duration`, mirroring extract's success/
error reporting.
- **▶ Preview** — same build to a temp file, then start the existing audition
QProcess(ffplay) on it (reuse `_stop_audition`/teardown machinery, or a small
shared `_play_file(path)`).
**Temp files:** rendered selection-clips and preview output live in the system
temp dir with unique names; a session set tracks them and `closeEvent` best-effort
removes them (extends the Phase-1 audition teardown).
---
## Persistence & migration
- QSettings gains `audio_crossfade` (last crossfade value). No DB/schema change.
- The merge sequence is **session-only** (not persisted) in v1 — it's a scratch
assembly surface, not a saved project. (Saved projects = "fancier stuff".)
## Testing
- `tests/test_utils.py`: TDD `build_crossfade_merge_command` — 1-clip re-encode,
2-clip acrossfade (`-filter_complex` contains `acrossfade=d=0.5`, maps `[out]`),
3-clip chained (two acrossfade stages), crossfade==0 → `concat`, codec-by-ext,
0-clip → ValueError.
- `tests/test_ui_structure.py`: Merge pane exists as a 3rd QToolBox page;
`_merge_list`/`_spn_crossfade`/buttons present; add-file appends a row;
remove/reorder mutate the list; `_on_merge_save` with an empty list is a safe
no-op.
## What this does NOT do (v1)
- No per-join crossfade durations or curve selection (single global value).
- No interactive waveform drag-select yet (whole-clip/selection granularity).
- No saved/reloadable merge projects (session-only sequence).
- No multi-track mixing/overlap beyond the crossfade at joins.
- No DB/dataset wiring (that's the deferred Phase 2 "dataset" work, separate).
@@ -0,0 +1,224 @@
# Audio Merge (Crossfade) — Phase 2 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** A "Merge" pane in the Audio tab that assembles an ordered list of audio clips into one output with a crossfade at every join, rendered via ffmpeg `acrossfade`. Clips come from the current extract selection (with its edits) or from files on disk.
**Architecture:** A pure `core/ffmpeg.build_crossfade_merge_command` (TDD) + a third `QToolBox` page in `_build_audio_tab`. Reuses Phase-1 pieces: `build_audio_clip_command`, `_current_edit_filters`, `probe_duration`, and the ffplay audition machinery (`_audition_proc`/`_stop_audition`/`_teardown_audition`).
**Tech Stack:** Python 3, PyQt6, ffmpeg (`acrossfade`/`concat` filters), pytest.
**Design doc:** `docs/plans/2026-07-02-audio-merge-design.md`
---
## Conventions
- Branch `audio-tab` (continues Phase 1). New commit per task; don't amend across tasks.
- UI tests: `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v`. Run `test_ui_structure.py` and `test_utils.py` in SEPARATE processes (known segfault when combined). Pre-existing red in `test_utils.py`: `test_audio_extract_timing`, `test_ffmpeg_command_no_resize`, `test_db_get_markers_returns_sorted` — untouched by this work.
---
## Task M1: `build_crossfade_merge_command` (pure, TDD)
**Files:** Modify `core/ffmpeg.py`; test `tests/test_utils.py`.
**Step 1 — failing tests:**
```python
def test_merge_single_clip_reencodes():
cmd = build_crossfade_merge_command(["/a.wav"], 0.5, "/o/out.mp3")
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
assert "libmp3lame" in cmd # codec by out ext
assert "acrossfade" not in " ".join(cmd)
assert cmd[-1] == "/o/out.mp3"
def test_merge_two_clips_acrossfade():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/out.wav")
assert cmd.count("-i") == 2
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]acrossfade=d=0.5" in fc
assert "[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
def test_merge_three_clips_chains():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 1.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert fc.count("acrossfade=d=1.0") == 2 # two joins
assert cmd[cmd.index("-map") + 1] == "[out]"
def test_merge_zero_crossfade_uses_concat():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "concat=n=2:v=0:a=1" in fc
assert "acrossfade" not in fc
def test_merge_empty_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command([], 0.5, "/o/o.wav")
```
**Step 2** — run `...pytest tests/test_utils.py -k merge -v` → FAIL.
**Step 3 — implement** in `core/ffmpeg.py` (near `build_audio_clip_command`):
```python
def build_crossfade_merge_command(clips: list[str], crossfade: float,
out_path: str) -> list[str]:
"""ffmpeg command that concatenates *clips* in order into *out_path*,
crossfading each join by *crossfade* seconds (0 = butt-join). Re-encoded
per the output extension."""
if not clips:
raise ValueError("no clips to merge")
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
cmd = [_bin("ffmpeg"), "-y"]
for c in clips:
cmd += ["-i", c]
if len(clips) == 1:
return cmd + ["-vn", *codec, out_path]
if crossfade > 0:
# Chain acrossfade: [0][1]->[a1]; [a1][2]->[a2]; … last label = [out].
parts, prev = [], "0"
for i in range(1, len(clips)):
label = "out" if i == len(clips) - 1 else f"a{i}"
parts.append(f"[{prev}][{i}]acrossfade=d={crossfade}[{label}]")
prev = label
fc = ";".join(parts)
else:
inputs = "".join(f"[{i}]" for i in range(len(clips)))
fc = f"{inputs}concat=n={len(clips)}:v=0:a=1[out]"
return cmd + ["-filter_complex", fc, "-map", "[out]", *codec, out_path]
```
Confirm `_bin` import in ffmpeg.py (it uses `_bin` already).
**Step 4**`...pytest tests/test_utils.py -k "merge or audio_clip" -v` → PASS (5 new + existing audio-clip tests).
**Step 5 — commit:** `git add core/ffmpeg.py tests/test_utils.py && git commit -m "feat: build_crossfade_merge_command — chained acrossfade / concat"`
---
## Task M2: Merge pane widgets + 3rd QToolBox page
**Files:** Modify `main.py`; test `tests/test_ui_structure.py`.
**Step 1 — failing test:**
```python
def test_merge_pane_present(win):
from PyQt6.QtWidgets import QToolBox, QListWidget, QDoubleSpinBox
tb = win._tab_audio.findChild(QToolBox)
titles = [tb.itemText(i) for i in range(tb.count())]
assert "Merge" in titles
assert isinstance(win._merge_list, QListWidget)
assert isinstance(win._spn_crossfade, QDoubleSpinBox)
assert win._spn_crossfade.value() == 0.5
```
**Step 2** — run → FAIL.
**Step 3 — implement:**
- In `__init__` (after the audio-edit widget block), construct: `self._merge_list = QListWidget()` (set `setSelectionMode(SingleSelection)`); `self._spn_crossfade = QDoubleSpinBox()` (range 0.010.0, decimals 2, step 0.1, suffix " s", value from QSettings `audio_crossfade` default 0.5, persist on change); the buttons `self._btn_merge_add_sel` (" Selection"), `self._btn_merge_add_file` (" File…"), `self._btn_merge_up` ("▲"), `self._btn_merge_down` ("▼"), `self._btn_merge_remove` ("✕"), `self._btn_merge_preview` ("▶ Preview"), `self._btn_merge_save` ("Merge & Save…"). Wire each `clicked` to its handler (defined in M3/M4; add stub methods now that `pass` — or define fully in later tasks and reference them). To keep TDD green, define minimal stub handlers now.
- In `_build_audio_tab`, add a third page: a `QWidget` with a `QVBoxLayout` holding the list, a crossfade row (`QLabel("Crossfade:")` + `_spn_crossfade`), a button grid (add/remove/reorder), and a bottom row (Preview, Merge & Save). `tb.addItem(merge_page, "Merge")`.
**Step 4** — run full `tests/test_ui_structure.py` → new test passes, prior 27 still pass.
**Step 5 — commit:** `feat: Merge pane (3rd Audio QToolBox page) — list + crossfade + buttons`
---
## Task M3: Add-to-sequence + reorder/remove
**Files:** `main.py`; test `tests/test_ui_structure.py`.
**Step 1 — failing test** (drives the list ops without file dialogs):
```python
def test_merge_list_add_remove_reorder(win, tmp_path):
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"
a.write_bytes(b""); b.write_bytes(b"")
win._merge_add_paths([str(a), str(b)])
assert win._merge_list.count() == 2
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(a)
win._merge_list.setCurrentRow(1)
win._merge_move(-1) # move b up
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(b)
win._merge_list.setCurrentRow(0)
win._merge_remove_selected()
assert win._merge_list.count() == 1
```
**Step 2** — run → FAIL.
**Step 3 — implement** on MainWindow:
- `_merge_add_paths(paths)` — for each path append a `QListWidgetItem` with text `basename (Ds)` (duration via `probe_duration`, blank if None) and `setData(UserRole, abspath)`.
- `_on_merge_add_file``QFileDialog.getOpenFileNames` (audio filter, remembered dir) → `_merge_add_paths`.
- `_on_merge_add_selection` — guard `_file_path`; render `[_cursor, _cursor+len]` with `_current_edit_filters()` to a unique temp wav (track it for cleanup) via `build_audio_clip_command` + `subprocess.run` under wait cursor; on success `_merge_add_paths([tmp])`; else status.
- `_merge_move(delta)` and `_merge_remove_selected()` — standard list row moves/removal.
- Replace the M2 stubs for add/file/selection/up/down/remove with these (up=`_merge_move(-1)`, down=`_merge_move(1)`).
**Step 4** — run full UI file → new test + prior pass.
**Step 5 — commit:** `feat: Merge sequence — add (file/selection), reorder, remove`
---
## Task M4: Merge & Save + Preview
**Files:** `main.py`; test `tests/test_ui_structure.py`.
**Step 1 — failing test** (patches the builder to capture args; sentinel-raises to avoid running ffmpeg):
```python
def test_merge_save_builds_command(win, tmp_path, monkeypatch):
import main as m, pytest
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"")
win._merge_add_paths([str(a), str(b)])
win._spn_crossfade.setValue(0.75)
seen = {}
class _Stop(Exception): pass
def fake(clips, xf, out):
seen["clips"] = clips; seen["xf"] = xf; seen["out"] = out
raise _Stop
monkeypatch.setattr(m, "build_crossfade_merge_command", fake)
monkeypatch.setattr(m.QFileDialog, "getSaveFileName",
staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), "")))
with pytest.raises(_Stop):
win._on_merge_save()
assert seen["clips"] == [str(a), str(b)]
assert seen["xf"] == 0.75
assert seen["out"].endswith(".wav")
def test_merge_save_empty_is_noop(win):
win._merge_list.clear()
win._on_merge_save() # must not raise / not open a dialog
```
**Step 2** — run → FAIL.
**Step 3 — implement:**
- `_merge_paths()` helper → list of UserRole paths in order.
- `import build_crossfade_merge_command` at the top with the other ffmpeg imports.
- `_on_merge_save` — if `_merge_paths()` empty → status + return; save dialog (reuse the extract save-filter builder / remembered dir); `cmd = build_crossfade_merge_command(paths, self._spn_crossfade.value(), out)`; run under wait cursor + status; on success report `probe_duration`, mirror extract's success/error handling.
- `_play_file(path)` — extract the ffplay-start logic from `_on_audio_audition` into this shared helper (render→play stays in audition; `_play_file` just starts `QProcess(ffplay)` on an existing file and reuses `_stop_audition`/teardown). Have `_on_audio_audition` call `_play_file(tmp)` after its render.
- `_on_merge_preview` — build to a unique temp wav, run, then `_play_file(tmp)`; guard empty list.
**Step 4** — run full UI file → new tests + prior pass; also re-run the audition test to confirm the `_play_file` refactor didn't break it.
**Step 5 — commit:** `feat: merge save + preview (crossfade render, ffplay preview)`
---
## Task M5: Temp cleanup, verify, docs
**Files:** `main.py`, `README.md`; verify.
- **Temp cleanup:** track rendered temp clips/preview outputs in a `self._merge_temps: set[str]`; best-effort `os.remove` them in `closeEvent` (add next to the audition teardown). Add `_stop_audition()` to `closeEvent` too (Phase-1 code-review follow-up).
- **Docs:** bump `APP_VERSION` "1.3"→"1.4"; add a `("1.4", [...])` changelog entry describing the Merge pane (assemble clips, crossfade at joins, add from selection/file, preview, save). README: add a bullet group under "Audio extraction & editing" for **Merge / crossfade**.
- **Verify:** run `tests/test_utils.py`, `tests/test_ui_structure.py`, `tests/test_audio_scan.py`, `tests/test_db.py` each in its own process; report counts; confirm the only failures are the 3 known pre-existing ones. `python -c "import main; print('OK')"` smoke.
- **Commit:** `docs: Merge pane (Phase 2) — v1.4 changelog + README + temp cleanup`
---
## Deferred (Phase 2-later / "fancier stuff")
- Interactive waveform drag-select (in/out handles, zoom, moving playhead) to define each added clip precisely.
- Per-join crossfade durations + curve selection (`c1=/c2=`), per-clip gain, drag-to-reorder.
- Saved/reloadable merge projects.
+155
View File
@@ -0,0 +1,155 @@
# Audio workspace tab — Design
**Goal:** Promote audio from a pair of cramped transport-row controls to a
first-class **Audio** deck tab that unifies everything audio: exact-length
extraction, light non-destructive editing of the extracted sound, an
audio-dedicated dataset workflow, and the existing scan/classify controls.
**Runs in:** Python/Qt client (`main.py`) + `core/` helpers. No server changes.
**Approach:** Replace the current **Scan** deck tab with an **Audio** tab.
Because the tab now covers four concerns, its body is a `QToolBox` accordion
(one pane open at a time) rather than flat stacked group-boxes. Existing
scan/extraction widgets are re-parented (same handlers/signals) — no behavior
change beyond relocation.
**Deck order after:** `Export | Crop & Track | Audio`. The `ScanResultsPanel`
(3rd splitter pane) is untouched.
Delivered in **two phases**. Phase 1 is self-contained and shippable; Phase 2
adds the dataset workflow.
---
## Deck wiring (both phases)
`_build_control_deck` (main.py:4661): swap `_tab_scan``_tab_audio`
`_deck_key="audio"`, `_label="Audio"`, `_pinned=False`; replace it in
`_deck_panels`; one `addTab`. The pin/split machinery is `_deck_panels`-driven,
so it picks the new panel up automatically. Old persisted `"scan"` pin-state is
ignored (harmless). Nothing auto-switches to the old scan tab — verified:
`_tab_scan` is referenced only in construction + its builder.
`_build_scan_tab` is renamed `_build_audio_tab` and rebuilt as a `QToolBox`
with the panes below.
---
## Phase 1 — Extract, Edit, Classify
### Pane 1 — Extract & Edit
**Relocated from the transport row** (removed there): `_spn_audio_len` (length)
and `_btn_extract_audio` (`♪ Extract`). The teal timeline region band is
unaffected — still driven by `_spn_audio_len`.
**New controls:**
- `_cmb_audio_fmt` — output format (WAV / MP3 / FLAC / M4A / OGG / OPUS),
persisted to QSettings `audio_extract_fmt`; pre-selects the save-dialog filter
so the format isn't re-picked each time.
- **Waveform strip** — a compact widget rendering the current region (or
last-extracted clip). Generated from decoded PCM (reuse
`core/audio_scan._load_audio_ffmpeg`) or `ffmpeg showwavespic`. Read-only in
v1 (no drag handles).
- **Play / Stop** — audition the current region/clip. Playback backend decided
in the plan (likely a short ffmpeg-decoded buffer through a
`QMediaPlayer`/`QSoundEffect`, kept separate from the mpv video player).
**Editing (non-destructive, ffmpeg filter chain at render time):**
| Control | Filter |
|---|---|
| Trim in / out (refine past the length spinbox) | `atrim` / adjusted `-ss` / `-t` |
| Fade in / out (s) | `afade=t=in` / `afade=t=out` |
| Normalize (toggle) | `loudnorm` |
| Gain (dB) | `volume` |
`core/ffmpeg.build_audio_clip_command` gains an optional `filters` param →
appended as `-af "<chain>"`. Foley/existing callers pass none → byte-identical
output. Extract applies the current edit settings; nothing is written until you
save.
### Pane 2 — Scan / Classify
Relocated verbatim from the old Scan tab: model combo + `_btn_model_history`;
`_btn_scan`, `_btn_auto_export`, `_btn_speech`, `_btn_scan_mode`;
`_spn_auto_fuse`, `_sld_threshold`. Same handlers, same `ScanResultsPanel`
output.
*(Train / Hard-negatives / Dataset-stats buttons join Pane 3 in Phase 2; in
Phase 1 they stay wherever they are today — the menu bar.)*
---
## Phase 2 — Audio dataset
### Pane 3 — Dataset (audio-dedicated)
A dedicated audio dataset **workflow + view**, combining browser-plus-label,
add-from-extraction, and in-tab access to the existing tools.
**Key data-model finding (the one open decision — confirm before building):**
the existing classifier trains on **(source_video, time-ranges)** per profile —
`db.get_training_data(...)` returns `(source_video_path, positive_times,
soft_times, negative_times)` per video; standalone clip *files* are not the
training unit. An extracted audio region always knows its
`(source_video, start, end)`. So the cheapest, most powerful integration is
**merged**: "Add to dataset" records the region as a labeled positive/negative
time-range in the **same** per-profile DB the video-export workflow already
feeds — no new trainer, no schema change. The Audio dataset pane is then an
**audio-oriented view/filter** over that shared dataset.
- **Recommended (merged):** audio extraction becomes another way to label
training data; reuses `get_training_data`, `get_training_stats`,
hard-negatives, and `train_classifier` unchanged.
- **Alternative (separate):** a standalone folder of `.wav` clips + own
`dataset.json` + a new file-based training path. More code, duplicates the
trainer. Only needed if the audio set must stay entirely independent of the
video profiles.
*(Brainstorming default was "separate"; inspecting the DB flips the
recommendation to "merged." Flagged for confirmation.)*
**Pane contents (assuming merged):**
- **Add to dataset** — from the current/last extraction:
`[class ▾]` + `(•positive / ○negative)` + `[+ Add]`. Writes a labeled
time-range into the DB for the active profile (and, for standalone-clip
parity, writes the clip's `dataset.json` label via `upsert_clip_annotation`
when a file was saved).
- **Browser** — a list of the audio dataset entries (class, pos/neg, source,
time-range), filterable by class; select to seek the source video to that
region; remove entries.
- **Tools** — in-tab buttons for the existing dialogs: *Dataset Statistics*
(`DatasetStatsDialog`), *Manage Hard Negatives* (`HardNegativesDialog`),
*Train classifier* (existing Train dialog).
---
## Persistence & migration
- QSettings gains `audio_extract_fmt` (+ edit defaults: fade/normalize/gain)
alongside existing `audio_extract_len`, `audio_extract_dir`.
- Deck pin-state key `"scan"``"audio"`; absent → default unpinned.
- Phase 2 (merged): no schema change — reuses existing training tables /
`dataset.json`. Phase 2 (separate) would add a folder convention only.
## Testing
- `tests/test_ui_structure.py`: update `test_extract_audio_controls_exist` to
locate controls in the Audio tab; add a test that the Audio deck tab exists as
a `QToolBox` with Extract/Classify panes and that scan controls remain
reachable.
- `tests/test_utils.py`: extend the audio-command tests for the new `-af`
filter chain (trim/fade/normalize/gain), keeping existing codec-by-extension
assertions.
- Phase 2: a DB test that "add to dataset" produces a training-data row
consumable by `get_training_data`.
## What this does NOT do
- No full waveform-drag editor in v1 — editing is parametric
(trim/fade/normalize/gain).
- No change to the Foley/LTX-2 export pipeline or `ScanResultsPanel`.
- No new ML model or embedding change.
- Phase 1 makes no DB/schema change; the dataset-model decision is confined to
Phase 2.
@@ -0,0 +1,648 @@
# Audio Workspace Tab — Phase 1 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the **Scan** control-deck tab with a first-class **Audio** tab (a `QToolBox` with *Extract & Edit* and *Scan / Classify* panes), relocate the extract controls off the transport row, add a persistent output-format picker, and add non-destructive parametric editing (fade / normalize / gain via an ffmpeg `-af` chain, plus trim by adjusting the extract window) with a read-only waveform strip and play/stop audition.
**Architecture:** Pure `core/ffmpeg.py` helper for the filter chain (fully unit-tested). `main.py` renames `_tab_scan``_tab_audio`, rebuilds its body as a `QToolBox`, moves the existing extract widgets into it, and adds the new edit widgets. No DB/schema change; Phase 2 (audio dataset) is deferred. The Foley/LTX-2 export path and `ScanResultsPanel` are untouched.
**Tech Stack:** Python 3, PyQt6, ffmpeg/ffprobe (via `core/ffmpeg.py`), pytest. UI tests use the existing `win` fixture in `tests/test_ui_structure.py` (offscreen Qt).
**Design doc:** `docs/plans/2026-07-02-audio-tab-design.md`
---
## Conventions
- Run one test: `pytest tests/test_utils.py::test_name -v`
- Run UI tests: `pytest tests/test_ui_structure.py -v` (needs the offscreen/LD_PRELOAD env the repo already uses — see `conftest.py`).
- Commit after every green step. Prefix messages `feat:` / `test:` / `refactor:`.
- Work on branch `audio-tab` (already created; design doc already committed there).
---
## Task 1: ffmpeg filter-chain helper + `filters` param
Non-destructive editing is applied at render time as an ffmpeg `-af` chain. The
chain builder is a pure function → fully unit-testable without invoking ffmpeg.
Trim is **not** a filter: trim-in/out just adjust the `start`/`duration` passed
to `build_audio_clip_command` (done in Task 5), so this task only covers
fade / normalize / gain.
**Files:**
- Modify: `core/ffmpeg.py` (add `audio_edit_filters`, extend `build_audio_clip_command` at lines 204-218)
- Test: `tests/test_utils.py` (after the existing audio-clip tests, ~line 77)
**Step 1: Write the failing tests**
```python
def test_audio_edit_filters_empty_when_defaults():
from core.ffmpeg import audio_edit_filters
assert audio_edit_filters(duration=3.0) == []
def test_audio_edit_filters_fade_normalize_gain():
from core.ffmpeg import audio_edit_filters
f = audio_edit_filters(duration=10.0, fade_in=0.5, fade_out=2.0,
normalize=True, gain_db=-3.0)
assert "afade=t=in:st=0:d=0.5" in f
# fade-out starts at duration - fade_out
assert "afade=t=out:st=8.0:d=2.0" in f
assert "loudnorm" in f
assert "volume=-3.0dB" in f
def test_audio_clip_command_no_filters_unchanged():
# No filters -> byte-identical to today's command (no -af).
cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav")
assert "-af" not in cmd
def test_audio_clip_command_appends_filter_chain():
cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav",
filters=["afade=t=in:st=0:d=0.5", "loudnorm"])
i = cmd.index("-af")
assert cmd[i + 1] == "afade=t=in:st=0:d=0.5,loudnorm"
# filter chain sits before the output path, after -vn/codec
assert i < len(cmd) - 1 and cmd[-1] == "/o/a.wav"
```
**Step 2: Run to verify they fail**
Run: `pytest tests/test_utils.py -k "audio_edit_filters or filter_chain or no_filters" -v`
Expected: FAIL (`audio_edit_filters` not defined; `filters` is an unexpected kwarg).
**Step 3: Implement**
Add above `build_audio_clip_command` in `core/ffmpeg.py`:
```python
def audio_edit_filters(duration: float, fade_in: float = 0.0,
fade_out: float = 0.0, normalize: bool = False,
gain_db: float = 0.0) -> list[str]:
"""Compose an ffmpeg -af chain for the non-destructive audio edits.
Trim is handled by the caller (it adjusts start/duration), so only
fade / normalize / gain appear here. Returns [] when nothing is set, so
the extract command stays byte-identical to the un-edited case."""
chain: list[str] = []
if fade_in > 0:
chain.append(f"afade=t=in:st=0:d={fade_in}")
if fade_out > 0:
st = max(0.0, duration - fade_out)
chain.append(f"afade=t=out:st={st}:d={fade_out}")
if normalize:
chain.append("loudnorm")
if gain_db != 0.0:
chain.append(f"volume={gain_db}dB")
return chain
```
Extend `build_audio_clip_command`:
```python
def build_audio_clip_command(input_path: str, start: float, duration: float,
out_path: str,
filters: "list[str] | None" = None) -> list[str]:
"""ffmpeg command to extract exactly *duration* seconds of audio starting
at *start*, re-encoded per *out_path*'s extension (wav/mp3/flac/…).
*filters* (if any) are joined into a single -af chain."""
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
af = ["-af", ",".join(filters)] if filters else []
return [
_bin("ffmpeg"), "-y",
"-ss", str(start),
"-i", input_path,
"-t", str(duration),
"-vn",
*codec,
*af,
out_path,
]
```
**Step 4: Run to verify pass**
Run: `pytest tests/test_utils.py -k audio -v`
Expected: PASS (new tests + the existing `test_audio_clip_command_*` still green).
**Step 5: Commit**
```bash
git add core/ffmpeg.py tests/test_utils.py
git commit -m "feat: audio_edit_filters helper + optional -af chain on build_audio_clip_command"
```
---
## Task 2: Rename the Scan deck tab → Audio (structure only)
Pure rename/re-key of the deck panel. The body still holds the scan grid for now
(Task 4 replaces it with the `QToolBox`). This isolates the deck-wiring change
from the layout change.
**Files:**
- Modify: `main.py` imports (line ~22), `_build_control_deck` (4666-4685), the builder call at 4498, and `_build_scan_tab` def (4745)
- Test: `tests/test_ui_structure.py` (new test near the audio tests, ~line 274)
**Step 1: Write the failing test**
```python
def test_audio_deck_tab_exists(win):
# The old "Scan" deck tab is now "Audio".
assert hasattr(win, "_tab_audio")
assert win._tab_audio._deck_key == "audio"
assert win._tab_audio in win._deck_panels
assert not hasattr(win, "_tab_scan")
labels = [win._control_deck.tabText(i)
for i in range(win._control_deck.count())]
assert "Audio" in labels and "Scan" not in labels
```
**Step 2: Run to verify it fails**
Run: `pytest tests/test_ui_structure.py::test_audio_deck_tab_exists -v`
Expected: FAIL (`_tab_scan` still present; no `_tab_audio`).
**Step 3: Implement**
1. Add `QToolBox, QGroupBox` to the `PyQt6.QtWidgets` import block (line ~22, next to `QTabWidget, QTabBar`):
```python
QTableWidget, QTableWidgetItem, QTabWidget, QTabBar, QHeaderView,
QToolBox, QGroupBox,
QGridLayout,
```
2. In `_build_control_deck`, replace the three `_tab_scan` lines (4668, 4678-4680) and the `_deck_panels`/`addTab` lines (4682, 4685):
```python
self._tab_audio = QWidget(); self._tab_audio.setObjectName("audio_tab")
...
self._tab_audio._pinned = False
self._tab_audio._label = "Audio"
self._tab_audio._deck_key = "audio"
# Ordered list for deterministic column / tab order.
self._deck_panels = [self._tab_export, self._tab_crop, self._tab_audio]
deck.addTab(self._tab_export, self._tab_export._label)
deck.addTab(self._tab_crop, self._tab_crop._label)
deck.addTab(self._tab_audio, self._tab_audio._label)
```
3. Rename the method `def _build_scan_tab(self)``def _build_audio_tab(self)` and change `QGridLayout(self._tab_scan)``QGridLayout(self._tab_audio)` (temporary; Task 4 rewrites the body).
4. Change the call at line 4498 `self._build_scan_tab()``self._build_audio_tab()`.
**Step 4: Run to verify pass**
Run: `pytest tests/test_ui_structure.py -v`
Expected: PASS (new test green; existing deck/pin tests still green).
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "refactor: rename Scan deck tab -> Audio (deck wiring only)"
```
---
## Task 3: Construct the new Audio widgets; drop extract controls from the transport row
Build the new edit widgets in `__init__` next to the existing extract controls
(~line 4438-4464), and stop adding `_spn_audio_len` / `_btn_extract_audio` to the
transport row (they move into the tab in Task 4). Keep the widgets as instance
attributes so `_build_audio_tab` can place them.
**Files:**
- Modify: `main.py` transport-row block (4441-4464)
- Test: `tests/test_ui_structure.py`
**Step 1: Write the failing test**
```python
def test_audio_edit_widgets_exist(win):
from PyQt6.QtWidgets import QComboBox, QCheckBox, QDoubleSpinBox
assert isinstance(win._cmb_audio_fmt, QComboBox)
assert win._cmb_audio_fmt.count() >= 3 # wav/mp3/flac at least
assert isinstance(win._spn_fade_in, QDoubleSpinBox)
assert isinstance(win._spn_fade_out, QDoubleSpinBox)
assert isinstance(win._chk_normalize, QCheckBox)
assert isinstance(win._spn_gain, QDoubleSpinBox)
```
**Step 2: Run to verify it fails**
Run: `pytest tests/test_ui_structure.py::test_audio_edit_widgets_exist -v`
Expected: FAIL (attributes don't exist).
**Step 3: Implement**
After the `_btn_extract_audio` construction (~line 4461), add:
```python
# Output format (persisted) — drives the extract extension + save filter.
self._cmb_audio_fmt = QComboBox()
for label, ext in (("WAV", ".wav"), ("MP3", ".mp3"), ("FLAC", ".flac"),
("M4A", ".m4a"), ("OGG", ".ogg"), ("OPUS", ".opus")):
self._cmb_audio_fmt.addItem(label, ext)
_saved_fmt = self._settings.value("audio_extract_fmt", ".wav")
_idx = self._cmb_audio_fmt.findData(_saved_fmt)
if _idx >= 0:
self._cmb_audio_fmt.setCurrentIndex(_idx)
self._cmb_audio_fmt.currentIndexChanged.connect(
lambda _i: self._settings.setValue(
"audio_extract_fmt", self._cmb_audio_fmt.currentData()))
# Non-destructive edit controls (applied via ffmpeg -af at extract time).
self._spn_fade_in = QDoubleSpinBox()
self._spn_fade_in.setRange(0.0, 30.0); self._spn_fade_in.setDecimals(2)
self._spn_fade_in.setSingleStep(0.1); self._spn_fade_in.setSuffix(" s")
self._spn_fade_in.setToolTip("Fade-in duration (0 = none)")
self._spn_fade_out = QDoubleSpinBox()
self._spn_fade_out.setRange(0.0, 30.0); self._spn_fade_out.setDecimals(2)
self._spn_fade_out.setSingleStep(0.1); self._spn_fade_out.setSuffix(" s")
self._spn_fade_out.setToolTip("Fade-out duration (0 = none)")
self._chk_normalize = QCheckBox("Normalize")
self._chk_normalize.setToolTip("Apply EBU R128 loudness normalization (loudnorm)")
self._spn_gain = QDoubleSpinBox()
self._spn_gain.setRange(-30.0, 30.0); self._spn_gain.setDecimals(1)
self._spn_gain.setSingleStep(0.5); self._spn_gain.setSuffix(" dB")
self._spn_gain.setToolTip("Gain applied to the extracted audio (0 = unchanged)")
```
Then **remove** these two lines (currently 4462-4463) so the controls no longer
sit in the transport row:
```python
transport_row.addWidget(self._spn_audio_len)
transport_row.addWidget(self._btn_extract_audio)
```
(Leave the `transport_row.addSpacing(12)` at 4440 if it now orphans a trailing
spacer — check the row visually in Task 8; drop the spacer if it looks odd.)
**Step 4: Run to verify pass**
Run: `pytest tests/test_ui_structure.py::test_audio_edit_widgets_exist -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: audio format + fade/normalize/gain widgets; free them from the transport row"
```
---
## Task 4: Rebuild `_build_audio_tab` as a `QToolBox` (Extract & Edit / Scan · Classify)
Replace the temporary scan grid with a two-page `QToolBox`. Page 1 places the
extract + edit widgets; Page 2 holds the (moved) scan grid.
**Files:**
- Modify: `main.py` `_build_audio_tab` (formerly `_build_scan_tab`, ~4745)
- Test: `tests/test_ui_structure.py`
**Step 1: Write the failing test**
```python
def test_audio_toolbox_has_two_panes(win):
from PyQt6.QtWidgets import QToolBox
tb = win._tab_audio.findChild(QToolBox)
assert tb is not None
titles = [tb.itemText(i) for i in range(tb.count())]
assert titles[0].startswith("Extract")
assert any("Scan" in t or "Classify" in t for t in titles)
# Extract controls now live under the Audio tab, not the transport row.
assert win._btn_extract_audio.parent() is not None
# Scan controls remain reachable.
assert win._btn_scan.isEnabled() in (True, False)
```
**Step 2: Run to verify it fails**
Run: `pytest tests/test_ui_structure.py::test_audio_toolbox_has_two_panes -v`
Expected: FAIL (no `QToolBox` under `_tab_audio`).
**Step 3: Implement**
Rewrite `_build_audio_tab` (keep the existing scan grid content — the `model_row`
`threshold` widgets — verbatim, just parented to the new `scan_page`):
```python
def _build_audio_tab(self) -> None:
outer = QVBoxLayout(self._tab_audio)
outer.setContentsMargins(0, 0, 0, 0)
tb = QToolBox()
# ── Page 1: Extract & Edit ───────────────────────────────
extract_page = QWidget()
g = QGridLayout(extract_page)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
g.addWidget(QLabel("Length:"), 0, 0)
g.addWidget(self._spn_audio_len, 0, 1)
g.addWidget(QLabel("Format:"), 0, 2)
g.addWidget(self._cmb_audio_fmt, 0, 3)
g.addWidget(self._group_sep(), 1, 0, 1, 4)
g.addWidget(QLabel("Fade in:"), 2, 0); g.addWidget(self._spn_fade_in, 2, 1)
g.addWidget(QLabel("Fade out:"), 2, 2); g.addWidget(self._spn_fade_out, 2, 3)
g.addWidget(self._chk_normalize, 3, 0, 1, 2)
g.addWidget(QLabel("Gain:"), 3, 2); g.addWidget(self._spn_gain, 3, 3)
# Waveform strip + audition land here in Tasks 6-7 (placeholder row 4).
g.addWidget(self._btn_extract_audio, 5, 0, 1, 4)
tb.addItem(extract_page, "Extract & Edit")
# ── Page 2: Scan / Classify (moved verbatim) ─────────────
scan_page = QWidget()
sg = QGridLayout(scan_page)
sg.setContentsMargins(8, 6, 8, 6); sg.setHorizontalSpacing(8); sg.setVerticalSpacing(6)
model_row = QHBoxLayout()
model_row.addWidget(self._cmb_scan_model, 1); model_row.addWidget(self._btn_model_history)
sg.addWidget(QLabel("Model:"), 0, 0); sg.addLayout(model_row, 0, 1, 1, 3)
sg.addWidget(self._group_sep(), 1, 0, 1, 4)
sg.addWidget(self._btn_scan, 2, 0); sg.addWidget(self._btn_auto_export, 2, 1)
sg.addWidget(self._btn_speech, 2, 2); sg.addWidget(self._btn_scan_mode, 2, 3)
sg.addWidget(self._group_sep(), 3, 0, 1, 4)
sg.addWidget(self._spn_auto_fuse, 4, 0); sg.addWidget(self._sld_threshold, 4, 1)
sg.setColumnStretch(3, 1)
tb.addItem(scan_page, "Scan / Classify")
outer.addWidget(tb)
```
> Confirm `QVBoxLayout` and `QLabel` are imported (they are — used throughout).
> If `QVBoxLayout` is missing from the import block, add it.
**Step 4: Run to verify pass**
Run: `pytest tests/test_ui_structure.py -v`
Expected: PASS. Update the existing `test_extract_audio_controls_exist` if it
assumed the transport row — it only checks widget types/attrs, so it stays green.
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: Audio tab QToolBox — Extract & Edit + Scan/Classify panes"
```
---
## Task 5: Wire format + edit params into `_on_extract_audio`
Extraction now honors the format picker (extension + save filter) and the edit
controls (fade/normalize/gain via `audio_edit_filters`; trim is deferred to the
optional trim spinboxes — see note). Keep the existing no-clamp behavior and
truncation reporting.
**Files:**
- Modify: `main.py` `_on_extract_audio` (6403-6457) + import (`audio_edit_filters` at line 38)
- Test: covered by `tests/test_utils.py` (command-level, Task 1) + manual (Task 8). Add one behavior test below.
**Step 1: Write the failing test** (in `tests/test_ui_structure.py`)
```python
def test_extract_uses_selected_format_and_edits(win, monkeypatch, tmp_path):
import core.ffmpeg as fx
win._file_path = "/x/video.mp4"
win._cursor = 5.0
win._spn_audio_len.setValue(4.0)
# pick MP3
win._cmb_audio_fmt.setCurrentIndex(win._cmb_audio_fmt.findData(".mp3"))
win._spn_fade_in.setValue(0.5)
captured = {}
def fake_cmd(inp, start, dur, out, filters=None):
captured.update(out=out, filters=filters or [])
return ["true"]
monkeypatch.setattr(win_module, "build_audio_clip_command", fake_cmd) # module alias
# short-circuit the save dialog to a temp mp3 and subprocess to success
monkeypatch.setattr(win_module.QFileDialog, "getSaveFileName",
staticmethod(lambda *a, **k: (str(tmp_path / "c.mp3"), "")))
monkeypatch.setattr(win_module.subprocess, "run",
lambda *a, **k: type("P", (), {"returncode": 0, "stderr": ""})())
monkeypatch.setattr(win_module.os.path, "exists", lambda p: True)
win._on_extract_audio()
assert captured["out"].endswith(".mp3")
assert any("afade=t=in" in f for f in captured["filters"])
```
> `win_module` = the imported `main` module in the test file; adjust to the
> fixture's existing import alias. If patching `os.path.exists` globally is too
> broad, patch `main.os.path.exists` only within the call or assert on
> `captured` before the exists check by making `fake_cmd` raise a sentinel.
**Step 2: Run to verify it fails**
Run: `pytest tests/test_ui_structure.py::test_extract_uses_selected_format_and_edits -v`
Expected: FAIL (format/edits not yet honored).
**Step 3: Implement**
In `_on_extract_audio`, replace the default-name/extension + command build:
```python
start = self._cursor
dur = self._spn_audio_len.value()
ext = self._cmb_audio_fmt.currentData() or ".wav"
fmt_label = self._cmb_audio_fmt.currentText()
stem = os.path.splitext(os.path.basename(self._file_path))[0]
default_name = f"{stem}_{start:.2f}-{start + dur:.2f}s{ext}"
default_dir = (self._settings.value("audio_extract_dir", "")
or self._tab_export_folder()
or os.path.dirname(self._file_path))
# Put the chosen format first in the filter list.
filters = (f"{fmt_label} (*{ext});;WAV (*.wav);;MP3 (*.mp3);;"
"FLAC (*.flac);;All files (*)")
path, _sel = QFileDialog.getSaveFileName(
self, "Save audio clip", os.path.join(default_dir, default_name), filters)
if not path:
return
if not os.path.splitext(path)[1]:
path += ext
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
edit = audio_edit_filters(
duration=dur,
fade_in=self._spn_fade_in.value(),
fade_out=self._spn_fade_out.value(),
normalize=self._chk_normalize.isChecked(),
gain_db=self._spn_gain.value())
cmd = build_audio_clip_command(self._file_path, start, dur, path,
filters=edit or None)
```
Add `audio_edit_filters` to the ffmpeg import at line 38:
```python
from core.ffmpeg import (build_audio_clip_command, audio_edit_filters,
probe_duration, ...) # keep existing names
```
**Step 4: Run to verify pass**
Run: `pytest tests/test_ui_structure.py -v && pytest tests/test_utils.py -k audio -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: extract honors format picker + fade/normalize/gain edits"
```
---
## Task 6: Read-only waveform strip
A compact widget that paints peak envelopes of the current region, refreshed on
cursor/length change. Decoding reuses `core/audio_scan._load_audio_ffmpeg`
(already returns mono float samples). Keep it dependency-free (QPainter).
**Files:**
- Create: `AudioWaveform(QWidget)` class in `main.py` (near `TimelineWidget`, ~line 1857) OR a small `core/waveform.py` for the peak-reduction (testable) + a thin painter in `main.py`.
- Modify: `_build_audio_tab` (add to row 4 of the extract grid), `_update_audio_region` (6394) to refresh peaks.
- Test: `tests/test_utils.py` for the pure peak-reduction; UI smoke test for the widget.
**Step 1: Write the failing test** (pure reduction in `core/waveform.py`)
```python
def test_peaks_downsamples_to_bucket_count():
from core.waveform import peaks
import numpy as np
samples = np.sin(np.linspace(0, 100, 10000)).astype("float32")
p = peaks(samples, buckets=64)
assert len(p) == 64
assert all(0.0 <= v <= 1.0 for v in p)
```
**Step 2: Run to verify it fails**`pytest tests/test_utils.py::test_peaks_downsamples_to_bucket_count -v` → FAIL (no module).
**Step 3: Implement** `core/waveform.py`:
```python
import numpy as np
def peaks(samples, buckets: int = 128) -> list[float]:
"""Reduce a 1-D sample array to *buckets* normalized peak magnitudes."""
if samples is None or len(samples) == 0:
return [0.0] * buckets
a = np.abs(np.asarray(samples, dtype="float32"))
idx = np.linspace(0, len(a), buckets + 1).astype(int)
out = [float(a[idx[i]:idx[i + 1]].max()) if idx[i + 1] > idx[i] else 0.0
for i in range(buckets)]
m = max(out) or 1.0
return [v / m for v in out]
```
Then add `AudioWaveform(QWidget)` in `main.py`: stores `self._peaks: list[float]`,
`set_peaks(p)` calls `update()`, `paintEvent` draws vertical bars centered on the
mid-line (teal, matching the timeline band `QColor(0,220,190)`). Fixed height ~48.
Place it at extract-grid row 4 spanning 4 columns. In `_update_audio_region`,
after computing `start`, decode the `[start, start+len]` slice off-thread (reuse
the scan prefetch pattern) or lazily on a "↻" button to avoid blocking the UI —
**decode must not run on the UI thread for long clips**; gate it behind a manual
refresh button in v1 (simplest, no threading).
**Step 4: Run to verify pass**`pytest tests/test_utils.py -k peaks -v` → PASS.
**Step 5: Commit**
```bash
git add core/waveform.py main.py tests/test_utils.py
git commit -m "feat: read-only waveform strip for the Audio tab"
```
---
## Task 7: Play / Stop audition
Audition the current region without disturbing the mpv video player. Simplest
robust approach: extract the region to a temp file via the existing command and
play it with `QMediaPlayer` + `QAudioOutput` (PyQt6.QtMultimedia), or shell out
to `ffplay`. Keep it a toggle button `_btn_audio_play`.
**Files:**
- Modify: `main.py` (`_btn_audio_play` construction in Task 3 block; handler `_on_audio_audition`; place button in `_build_audio_tab`).
- Test: UI smoke test that the button exists and toggling with no file is a no-op.
**Step 1: Write the failing test**
```python
def test_audition_button_exists_and_safe_without_file(win):
from PyQt6.QtWidgets import QPushButton
assert isinstance(win._btn_audio_play, QPushButton)
win._file_path = ""
win._on_audio_audition() # must not raise
```
**Step 2: Run** → FAIL (no `_btn_audio_play`).
**Step 3: Implement** — construct `_btn_audio_play` (checkable, "▶ Play") in the
Task 3 widget block; add to the extract grid next to the waveform; handler
renders the current region (respecting edits) to a temp file under the scratch
dir and plays via `QMediaPlayer`; toggling off stops. Guard on `self._file_path`.
Decide `QMediaPlayer` vs `ffplay` during implementation; prefer `QMediaPlayer`
(no extra process, stops cleanly). If QtMultimedia is unavailable in the frozen
build, fall back to `ffplay -autoexit -nodisp`.
**Step 4: Run** → PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: play/stop audition of the current audio region"
```
---
## Task 8: Full test pass, visual check, docs
**Step 1:** Run the whole suite:
```bash
pytest tests/ -v
```
Expected: all green. Fix any test that assumed the old transport-row placement.
**Step 2:** Launch the app (use the `/run` skill or `./8cut.sh`), load a video,
and verify by observation:
- Audio tab present as 3rd deck tab; `QToolBox` opens on *Extract & Edit*.
- Length + Format + edit controls present; transport row no longer shows them.
- Teal timeline band still tracks cursor + length.
- Extract with MP3 + fade-in produces an mp3 that fades in (spot-check with a
player); status line reports the saved length.
- Waveform refresh draws bars; Play auditions; Scan/Classify pane still scans.
**Step 3:** Update `README.md` + changelog: note the Audio tab replaces the Scan
tab and adds format + fade/normalize/gain + audition. Mention Phase 2 (audio
dataset) is planned.
**Step 4: Commit**
```bash
git add README.md
git commit -m "docs: Audio workspace tab (Phase 1) — changelog + README"
```
---
## Deferred to Phase 2 (separate plan)
- Audio dataset pane (browser + label, add-from-extraction, in-tab Stats /
Hard-negatives / Train). **Blocked on the merged-vs-separate decision** in the
design doc (recommended: merged — write labeled `(source_video, start, end)`
time-ranges into the existing per-profile training DB).
## Risks / watch-outs
- **`loudnorm` is single-pass here** — fine for audition/dataset clips; if you
need broadcast-accurate normalization, that's a two-pass follow-up.
- **Waveform/audition decode off the UI thread** — v1 gates waveform behind a
manual refresh and audition behind a temp render; do not decode long clips
synchronously on the UI thread.
- **Deck pin persistence** — old `"scan"` pin key is silently dropped; verify the
side-by-side (pin two panels) still works with the Audio panel.
- **`test_extract_uses_selected_format_and_edits`** monkeypatch details depend on
how `main` imports `build_audio_clip_command`/`QFileDialog`/`subprocess`;
adapt the patch targets to the actual module namespace.
@@ -0,0 +1,677 @@
# Audio Timeline Picker And Heal Cut Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the existing audio timeline band practical for picking regions and add a one-click Heal Cut editor workflow for removing short artifacts and rejoining the audio smoothly.
**Architecture:** Keep the current single-lane `TimelineWidget` as the source-region picker and keep `MainWindow` as the synchronization owner between timeline, cursor, length spinbox, and waveform. Add a pure `core.ffmpeg.build_audio_heal_delete_command()` command builder, then wire it into `AudioEditorDialog` as the primary destructive operation using the existing rendered-version undo/redo stack.
**Tech Stack:** Python 3, PyQt6, ffmpeg filtergraphs, pytest. Run `tests/test_ui_structure.py` and `tests/test_utils.py` in separate processes with `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen`.
---
## Baseline
Run before implementation:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -q
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -q
```
Expected baseline:
- `tests/test_ui_structure.py`: 70 passed.
- `tests/test_utils.py`: 90 passed, 3 known pre-existing failures:
- `test_ffmpeg_command_no_resize`
- `test_db_get_markers_returns_sorted`
- `test_audio_extract_timing`
## File Structure
- `core/ffmpeg.py`: add pure Heal Cut command builder and small helper for automatic fade duration.
- `tests/test_utils.py`: add pure tests for Heal Cut command generation.
- `main.py`: import builder, improve `TimelineWidget` audio interactions, add editor Heal Cut UI, join preview range, loop playback, and Save to Library.
- `tests/test_ui_structure.py`: add focused Qt structure/behavior tests for timeline picking and editor integration.
## Task 1: Pure Heal Cut ffmpeg Builder
**Files:**
- Modify: `core/ffmpeg.py`
- Modify: `tests/test_utils.py`
- [ ] **Step 1: Write failing tests**
Append these tests after `test_audio_delete_empty_head` in `tests/test_utils.py`:
```python
def test_audio_heal_delete_command_crossfades_join():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 2.0, 4.0, "/o/o.wav", crossfade=0.1)
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=2.0" in fc
assert "atrim=start=4.0" in fc
assert "acrossfade=d=0.1:c1=qsin:c2=qsin[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
assert "pcm_s16le" in cmd
assert cmd[-1] == "/o/o.wav"
def test_audio_heal_delete_command_auto_crossfade_clamped():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 10.0, 12.0, "/o/o.mp3")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.25:c1=qsin:c2=qsin[out]" in fc
assert "libmp3lame" in cmd
def test_audio_heal_delete_command_near_start_shortens_crossfade():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 0.03, 1.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.03:c1=qsin:c2=qsin[out]" in fc
def test_audio_heal_delete_command_rejects_invalid_region():
import pytest
from core.ffmpeg import build_audio_heal_delete_command
with pytest.raises(ValueError):
build_audio_heal_delete_command("/in.wav", 3.0, 3.0, "/o/o.wav")
```
- [ ] **Step 2: Verify RED**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -k "audio_heal_delete" -q
```
Expected: collection/import failure or test failure because `build_audio_heal_delete_command` does not exist.
- [ ] **Step 3: Implement minimal builder**
In `core/ffmpeg.py`, add this helper and command builder after `build_audio_delete_command`:
```python
def _auto_heal_crossfade(start: float, end: float,
requested: float | None = None) -> float:
if end <= start:
raise ValueError("heal delete end must be greater than start")
if requested is not None:
fade = max(0.0, float(requested))
else:
fade = min(0.25, max(0.04, (end - start) * 0.25))
# Without knowing total duration, clamp only to available pre-roll.
fade = min(fade, max(0.0, float(start)))
return round(fade, 3)
def build_audio_heal_delete_command(input_path: str, start: float, end: float,
out_path: str,
crossfade: float | None = None) -> list[str]:
"""Remove [start, end] and heal the join with a short equal-power crossfade."""
if end <= start:
raise ValueError("heal delete end must be greater than start")
s, e = round(start, 3), round(end, 3)
xf = _auto_heal_crossfade(s, e, crossfade)
ext = os.path.splitext(out_path)[1].lower()
codec = _AUDIO_CODEC_BY_EXT.get(ext, [])
if xf <= 0:
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]concat=n=2:v=0:a=1[out]")
else:
fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];"
f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];"
f"[a][b]acrossfade=d={xf}:c1=qsin:c2=qsin[out]")
return [_bin("ffmpeg"), "-y", "-i", input_path,
"-filter_complex", fc, "-map", "[out]", *codec, out_path]
```
- [ ] **Step 4: Verify GREEN**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -k "audio_heal_delete or audio_delete or audio_silence or audio_reverse" -q
```
Expected: all selected tests pass.
- [ ] **Step 5: Commit**
```bash
git add core/ffmpeg.py tests/test_utils.py
git commit -m "feat: add heal cut ffmpeg command"
```
## Task 2: Timeline Audio Band Interaction Upgrade
**Files:**
- Modify: `main.py`
- Modify: `tests/test_ui_structure.py`
- [ ] **Step 1: Write failing tests**
Add these tests after `test_timeline_audio_band_resize_right` in `tests/test_ui_structure.py`:
```python
def test_timeline_audio_click_outside_moves_region_start(win):
tl = win._timeline
tl._duration = 20.0
tl._view_start = 0.0
tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
got = []
tl.audio_region_changed.connect(lambda s, e: got.append((s, e)))
tl._audio_begin_drag_at_x(tl._time_to_x(12.0))
tl._audio_end_drag()
assert got[-1] == tl._audio_region
s, e = tl._audio_region
assert abs(s - 12.0) < 0.1
assert abs((e - s) - 4.0) < 0.1
def test_timeline_audio_drag_empty_creates_region(win):
tl = win._timeline
tl._duration = 20.0
tl._view_start = 0.0
tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
tl._audio_begin_drag_at_x(tl._time_to_x(12.0))
tl._audio_drag_to_x(tl._time_to_x(15.0))
tl._audio_end_drag()
s, e = tl._audio_region
assert abs(s - 12.0) < 0.1
assert abs(e - 15.0) < 0.1
def test_timeline_audio_hover_cursor_state(win):
tl = win._timeline
tl._duration = 20.0
tl._view_start = 0.0
tl._view_span = 20.0
tl.resize(400, tl.height() or 80)
tl.set_audio_mode(True)
tl.set_audio_region(4.0, 8.0)
assert tl._audio_hit_at_x(tl._time_to_x(4.0)) == "left"
assert tl._audio_hit_at_x(tl._time_to_x(8.0)) == "right"
assert tl._audio_hit_at_x(tl._time_to_x(6.0)) == "move"
assert tl._audio_hit_at_x(tl._time_to_x(12.0)) == "create"
```
- [ ] **Step 2: Verify RED**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "timeline_audio" -q
```
Expected: failures because `_audio_hit_at_x` does not exist and empty-space click/drag does not create/move the region.
- [ ] **Step 3: Implement minimal timeline changes**
In `TimelineWidget.__init__`, change `_AUDIO_EDGE_PX = 6` to `_AUDIO_EDGE_PX = 10`.
Add a helper near `_audio_begin_drag_at_x`:
```python
def _audio_hit_at_x(self, x: float) -> str | None:
if not self._audio_mode or self._audio_region is None:
return None
a0, a1 = self._audio_region
ax0 = self._time_to_x(a0)
ax1 = self._time_to_x(a1)
if abs(x - ax0) <= self._AUDIO_EDGE_PX:
return "left"
if abs(x - ax1) <= self._AUDIO_EDGE_PX:
return "right"
if ax0 < x < ax1:
return "move"
return "create"
```
Update `_audio_begin_drag_at_x` so `"create"` preserves current width until the pointer moves:
```python
def _audio_begin_drag_at_x(self, x: float) -> None:
if not self._audio_mode or self._audio_region is None:
return
hit = self._audio_hit_at_x(x)
a0, a1 = self._audio_region
t = max(0.0, min(self._pos_to_time(int(x)), self._duration))
self._audio_drag = hit
if hit == "move":
self._audio_drag_anchor = t - a0
elif hit == "create":
width = max(self._AUDIO_MIN_W, a1 - a0)
a0 = max(0.0, min(t, max(0.0, self._duration - width)))
self._audio_region = (a0, a0 + width)
self.update()
if self._audio_drag is not None:
self._audio_drag_orig = self._audio_region
```
Update `_audio_drag_to_x` with a create branch:
```python
elif self._audio_drag == "create":
anchor = self._audio_drag_orig[0] if self._audio_drag_orig else a0
a0 = min(anchor, t)
a1 = max(anchor + self._AUDIO_MIN_W, max(anchor, t))
```
Update `mouseMoveEvent` hover cursor logic before marker hover:
```python
if self._audio_mode and self._audio_region is not None:
hit = self._audio_hit_at_x(x)
if hit in ("left", "right"):
self.setCursor(Qt.CursorShape.SizeHorCursor)
elif hit == "move":
self.setCursor(Qt.CursorShape.OpenHandCursor)
elif hit == "create":
self.setCursor(Qt.CursorShape.CrossCursor)
return
```
In `paintEvent`, draw handle rectangles in Audio mode after the audio band edge lines:
```python
p.fillRect(ax1 - 4, rh, 8, th, QColor(0, 220, 190, 120))
p.fillRect(ax2 - 4, rh, 8, th, QColor(0, 220, 190, 120))
```
- [ ] **Step 4: Verify GREEN**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "timeline_audio" -q
```
Expected: all selected timeline audio tests pass.
- [ ] **Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: improve audio region timeline picking"
```
## Task 3: Editor Heal Cut UI And Undo Integration
**Files:**
- Modify: `main.py`
- Modify: `tests/test_ui_structure.py`
- [ ] **Step 1: Write failing tests**
Update `test_audio_editor_dialog_scaffold` so the button list includes `_btn_heal_cut`:
```python
for name in ("_btn_heal_cut", "_btn_delete", "_btn_silence", "_btn_reverse",
"_btn_trim", "_btn_undo", "_btn_redo", "_btn_save_as"):
assert isinstance(getattr(dlg, name), QPushButton)
```
Add this test after `test_editor_delete_builds_command`:
```python
def test_editor_heal_cut_builds_command(win, tmp_path, monkeypatch):
import main as m, pytest
src = tmp_path / "v0.wav"
src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 6.0
dlg._wave.set_view(0.0, 6.0)
dlg._wave.set_selection(2.0, 3.0)
seen = {}
class _Stop(Exception):
pass
def fake(inp, s, e, out):
seen.update(inp=inp, s=s, e=e, out=out)
raise _Stop
monkeypatch.setattr(m, "build_audio_heal_delete_command", fake)
with pytest.raises(_Stop):
dlg._on_heal_cut()
assert seen["inp"] == str(src)
assert seen["s"] == 2.0 and seen["e"] == 3.0
assert seen["out"].endswith(".wav")
```
- [ ] **Step 2: Verify RED**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "audio_editor_dialog_scaffold or editor_heal_cut" -q
```
Expected: failures because `_btn_heal_cut`, `_on_heal_cut`, and the import do not exist.
- [ ] **Step 3: Implement minimal editor wiring**
In `main.py`, update the `from core.ffmpeg import` block so this line:
```python
build_audio_delete_command, build_audio_silence_command,
build_audio_reverse_command,
```
becomes:
```python
build_audio_delete_command, build_audio_silence_command,
build_audio_reverse_command, build_audio_heal_delete_command,
```
In `AudioEditorDialog.__init__`, create the button before `_btn_delete`:
```python
self._btn_heal_cut = QPushButton("Heal Cut")
```
Wire it in the button/slot tuple:
```python
(self._btn_heal_cut, self._on_heal_cut),
```
Add it first in the ops row:
```python
ops.addWidget(self._btn_heal_cut)
for b in (self._btn_delete, self._btn_silence, self._btn_reverse,
self._btn_trim):
ops.addWidget(b)
```
Add the handler next to `_on_delete`:
```python
def _on_heal_cut(self):
self._apply_op(build_audio_heal_delete_command, whole_clip_ok=False,
after_success=self._prepare_join_preview)
```
Update `_apply_op` signature and its successful-render block. The function header becomes:
```python
def _apply_op(self, build_fn, whole_clip_ok: bool = True,
after_success=None) -> None:
```
The successful-render block becomes:
```python
if (proc is not None and proc.returncode == 0
and os.path.exists(tmp) and os.path.getsize(tmp) > 0):
del self._versions[self._ver_idx + 1:]
self._versions.append(tmp)
self._ver_idx += 1
self._temps.add(tmp)
self._reload()
if after_success is not None:
after_success(s)
self._set_status("")
```
Add a no-op preview method for this task:
```python
def _prepare_join_preview(self, seam_t: float) -> None:
self._join_preview = (max(0.0, seam_t - 1.0), seam_t + 1.0)
```
Initialize before `_reload()`:
```python
self._join_preview: tuple[float, float] | None = None
```
- [ ] **Step 4: Verify GREEN**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "audio_editor_dialog_scaffold or editor_heal_cut or editor_delete" -q
```
Expected: selected editor tests pass.
- [ ] **Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: add heal cut editor action"
```
## Task 4: Join Loop Preview And Save To Library
**Files:**
- Modify: `main.py`
- Modify: `tests/test_ui_structure.py`
- [ ] **Step 1: Write failing tests**
Add this test after `test_editor_play_stop_safe`:
```python
def test_editor_loop_join_plays_preview_range(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"
src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._join_preview = (1.0, 3.0)
seen = {}
monkeypatch.setattr(dlg, "_play_current", lambda start=None, end=None: seen.update(start=start, end=end))
dlg._on_loop_join()
assert seen == {"start": 1.0, "end": 3.0}
```
Add this test after the library tests:
```python
def test_editor_save_to_library_adds_current_version(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"
src.write_bytes(b"")
out_dir = tmp_path / "library"
win._settings.setValue("audio_library_dir", str(out_dir))
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 2.0
saved = []
monkeypatch.setattr(m, "build_audio_clip_command",
lambda inp, start, dur, out: ["ffmpeg", out])
def fake_run(cmd, capture_output=True, timeout=300):
target = cmd[-1]
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "wb") as f:
f.write(b"x")
class Proc:
returncode = 0
return Proc()
monkeypatch.setattr(m.subprocess, "run", fake_run)
win._scan_panel._library.add_clip = lambda p: saved.append(p)
dlg._on_save_to_library()
assert saved
assert saved[0].startswith(str(out_dir))
```
- [ ] **Step 2: Verify RED**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "loop_join or save_to_library" -q
```
Expected: failures because `_on_loop_join`, `_play_current`, `_btn_loop_join`, `_btn_save_library`, and `_on_save_to_library` do not exist.
- [ ] **Step 3: Implement playback helper and save button**
In `AudioEditorDialog.__init__`, add:
```python
self._btn_loop_join = QPushButton("Loop Join")
self._btn_save_library = QPushButton("Save to Library")
```
Wire buttons:
```python
(self._btn_loop_join, self._on_loop_join),
(self._btn_save_library, self._on_save_to_library),
```
Add `_btn_loop_join` beside play and `_btn_save_library` before Save As.
Replace `_on_play` playback start with a helper:
```python
def _on_play(self, checked: bool) -> None:
if not checked:
self._stop_play()
return
self._play_current()
def _play_current(self, start: float | None = None,
end: float | None = None) -> None:
from PyQt6.QtCore import QProcess
self._stop_play()
self._play_proc = QProcess(self)
self._play_proc.finished.connect(self._on_play_finished)
self._play_proc.errorOccurred.connect(self._on_play_error)
args = ["-autoexit", "-nodisp", "-loglevel", "error"]
if start is not None:
args += ["-ss", str(start)]
if end is not None and start is not None and end > start:
args += ["-t", str(end - start)]
args.append(self._current())
self._play_proc.start(_bin("ffplay"), args)
self._btn_play.setText("■ Stop")
if not self._btn_play.isChecked():
self._btn_play.blockSignals(True)
self._btn_play.setChecked(True)
self._btn_play.blockSignals(False)
```
Add loop handler:
```python
def _on_loop_join(self) -> None:
if not self._join_preview:
self._set_status("No healed join to preview")
return
self._play_current(*self._join_preview)
```
Add save-to-library handler:
```python
def _on_save_to_library(self) -> None:
parent = self.parent()
settings = getattr(parent, "_settings", None)
scan_panel = getattr(parent, "_scan_panel", None)
if settings is None or scan_panel is None:
self._set_status("Library unavailable")
return
base = settings.value("audio_library_dir", "")
if not base:
base = os.path.join(str(Path.home()), "8cut_audio_library")
settings.setValue("audio_library_dir", base)
try:
os.makedirs(base, exist_ok=True)
except OSError:
self._set_status("Could not create library folder")
return
stem = os.path.splitext(os.path.basename(self._current()))[0] or "clip"
out = os.path.join(base, f"{stem}_edited.wav")
i = 1
while os.path.exists(out):
out = os.path.join(base, f"{stem}_edited_{i}.wav")
i += 1
dur = probe_duration(self._current()) or self._clip_dur or 0.0
cmd = build_audio_clip_command(self._current(), 0.0, dur, out)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
proc = subprocess.run(cmd, capture_output=True, timeout=300)
except Exception:
proc = None
finally:
QApplication.restoreOverrideCursor()
if proc is not None and proc.returncode == 0 and os.path.exists(out):
scan_panel._library.add_clip(out)
self._last_saved = out
self._set_status(f"Saved to library: {os.path.basename(out)}")
else:
self._set_status("Save to library failed")
```
- [ ] **Step 4: Verify GREEN**
Run:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -k "audio_editor_dialog_scaffold or editor_play_stop_safe or loop_join or save_to_library" -q
```
Expected: selected tests pass.
- [ ] **Step 5: Commit**
```bash
git add main.py tests/test_ui_structure.py
git commit -m "feat: preview healed joins and save editor clips to library"
```
## Task 5: Final Verification
**Files:**
- Verify only; no file changes expected.
- [ ] **Step 1: Run full UI tests**
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -q
```
Expected: all tests pass.
- [ ] **Step 2: Run utility tests**
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -q
```
Expected: new Heal Cut tests pass; the same three baseline failures remain unless unrelated baseline fixes were made:
- `test_ffmpeg_command_no_resize`
- `test_db_get_markers_returns_sorted`
- `test_audio_extract_timing`
- [ ] **Step 3: Import smoke**
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -c "import main; print('OK')"
```
Expected: prints `OK`.
- [ ] **Step 4: Review git diff**
```bash
git status --short
git diff --stat HEAD~4..HEAD
```
Expected: commits touch only `core/ffmpeg.py`, `main.py`, `tests/test_utils.py`, and `tests/test_ui_structure.py`, plus this plan commit if it was committed separately.
@@ -0,0 +1,204 @@
# Audio Timeline Picker And Heal Cut Editor Design
## Goal
Make audio editing practical by splitting the workflow across two clear surfaces:
- The main timeline is the fast, precise audio region picker.
- The clip editor is the finishing surface for one selected clip, centered on removing short noise artifacts and rejoining the audio cleanly.
This pass focuses on the common workflow: select a bad 1-2 second artifact, remove it, heal the join, audition the seam, then save the result.
## Current Context
Recent audio work made the Audio tab first-class and added an audio region band, waveform selection, a clip library, merge support, and a destructive clip editor. The existing implementation is functionally broad, but the interaction model is still weak:
- The timeline audio band has small edge hit targets and poor discoverability.
- The timeline has a playhead, but in Audio mode the relationship between the yellow cursor and teal audio region is not clear enough.
- The editor supports Delete, Silence, Reverse, Trim, undo/redo, playback, and Save As, but it lacks a purpose-built artifact-removal operation.
- The merge/crossfade pane is useful, but the immediate need is better one-clip cleanup rather than multi-clip assembly.
## Approved Scope
In scope:
- Upgrade the existing single-lane timeline audio band rather than adding a separate audio lane.
- Make the teal audio band easy to move, resize, and create with the mouse.
- Keep the yellow playhead/cursor visible and visually distinct in Audio mode.
- Keep the timeline band, length spinbox, and waveform selection synchronized.
- Add a primary `Heal Cut` operation to `AudioEditorDialog`.
- Keep existing editor operations as secondary tools: Hard Delete, Silence, Reverse, Trim.
- Add join-focused loop audition after Heal Cut.
- Add clearer save actions: Save to Library and Save As.
- Store Heal Cut outputs in the existing temp-version undo/redo stack.
- Add a pure ffmpeg command builder in `core/ffmpeg.py` so heal behavior is testable outside Qt.
Out of scope for this pass:
- A separate timeline audio lane.
- A full multitrack editor.
- Saved editor projects.
- Spectral repair or ML audio inpainting.
- Major changes to the merge/crossfade pane.
- Database or schema changes.
## Timeline Picker Design
`TimelineWidget` remains the audio picking surface in Audio mode.
Interaction changes:
- Increase the audio band edge hit target and draw larger visible handles.
- Change the cursor on hover:
- left/right edge: horizontal resize
- inside band: move
- outside band: set/create region
- Drag an edge to resize the region.
- Drag inside the region to move it while preserving length.
- Click outside the region to set the audio region start at that time while preserving length.
- Drag from empty timeline space to create a new region.
- Emit `audio_region_changed(start, end)` on completed user edits, as today.
Visual changes:
- Keep the teal band as the audio region.
- Use brighter/solid handle styling in Audio mode.
- Keep the yellow cursor/playhead visible at the current start/play position.
- Avoid showing the normal blue export clip span in Audio mode.
- Preserve scan-region edge behavior and normal non-audio export behavior outside Audio mode.
Sync behavior:
- `MainWindow` remains the owner of synchronization between:
- `TimelineWidget._audio_region`
- `_cursor`
- `_spn_audio_len`
- `AudioWaveform` selection
- A timeline audio edit updates `_cursor` and audio length.
- A length spinbox edit updates the timeline audio band and waveform selection.
- A waveform selection edit updates `_cursor`, the length spinbox, and the timeline audio band.
## Editor UI Design
`AudioEditorDialog` becomes a focused one-clip cleanup tool.
Layout:
- Large waveform area with selection handles.
- Primary action panel with `Heal Cut`.
- Secondary destructive tools:
- Hard Delete
- Silence
- Reverse
- Trim
- Playback controls:
- Play clip
- Play selection
- Loop Join
- History:
- Undo
- Redo
- Save:
- Save to Library
- Save As
Behavior:
- Heal Cut is the recommended/default artifact-removal action.
- Hard Delete remains available for cases where no smoothing is wanted.
- After a successful Heal Cut, the editor sets a join preview range and starts or enables looping around the healed seam.
- Save to Library writes the current edited version into the managed audio library folder and adds it to `AudioLibraryTab`.
- Save As keeps the existing file-dialog path.
## Heal Cut Behavior
Primary workflow:
1. User selects the artifact region in the editor waveform.
2. User clicks `Heal Cut`.
3. The editor renders a new temp version.
4. The selected artifact is removed.
5. The before/after audio is rejoined with an automatic short equal-power crossfade.
6. The editor reloads the new version, pushes it onto the undo stack, and prepares a loop preview around the seam.
Command builder:
Add this pure function in `core/ffmpeg.py`:
```python
def build_audio_heal_delete_command(
input_path: str,
start: float,
end: float,
out_path: str,
crossfade: float | None = None,
) -> list[str]:
```
Automatic crossfade:
- If `crossfade` is omitted, choose it automatically.
- Use a small practical range: 40-250 ms.
- Base the chosen duration on the selected artifact length and nearby available material.
- Clamp crossfade so it fits before `start` and after `end`.
- If no crossfade can fit, fall back to the shortest valid fade or hard delete and report that in editor status.
Implementation shape:
- Build the output from the kept region before the artifact and the kept region after the artifact.
- Overlap the join by the chosen crossfade duration.
- Use ffmpeg `acrossfade` with `qsin` for both sides (`c1=qsin:c2=qsin`), matching the existing curve list in the merge code.
- Keep output codec behavior aligned with the existing audio command helpers.
Zero-crossing:
- Zero-crossing edge adjustment is desirable, but should not block the first useful version.
- First pass should deliver automatic equal-power Heal Cut via ffmpeg.
- Treat sample-level zero-crossing adjustment as a deferred enhancement after the ffmpeg-based Heal Cut is working and tested.
## Error Handling
Heal Cut guardrails:
- If no selection exists, show `Select an artifact first`.
- If the selection is too short, show a status message and do not edit.
- If the selection would remove the whole clip, block it.
- If the selected region is too close to the start or end for the preferred crossfade, use a shorter valid fade.
- If ffmpeg fails, keep the current version unchanged and remove the temp output.
- If the output is empty or missing, treat it as failure.
Timeline guardrails:
- Audio-mode mouse behavior must not affect non-audio export mode.
- Audio-band interactions must not interfere with Shift-drag scan-region resizing.
- Re-entrant synchronization between timeline, waveform, and spinbox must stay guarded.
## Testing Plan
Unit tests in `tests/test_utils.py`:
- `build_audio_heal_delete_command` normal case uses a crossfade join.
- Explicit crossfade duration is respected.
- Automatic crossfade is clamped to a practical range.
- Near-start and near-end selections do not produce negative trim times.
- Whole-clip or invalid selections are rejected by the caller or produce a clear error path.
- Existing Delete/Silence/Reverse command tests continue to pass.
UI tests in `tests/test_ui_structure.py`:
- Timeline audio band exposes larger handle behavior through direct helper tests.
- Clicking/dragging empty audio-mode timeline space can create or reset an audio region.
- Timeline audio edit still syncs `_cursor` and `_spn_audio_len`.
- `AudioEditorDialog` has a primary Heal Cut button.
- Heal Cut invokes the new command builder and pushes a new undo version on success.
- Heal Cut failure leaves the current version unchanged.
- Save to Library adds the saved current version to the library.
Verification commands should keep the repo's existing isolation rule:
```bash
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_ui_structure.py -v
LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen python -m pytest tests/test_utils.py -v
```
Do not combine the Qt structure tests and utility tests in one process.
+5023 -523
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+312
View File
@@ -1,5 +1,6 @@
import tempfile, os, json import tempfile, os, json
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, resolve_keyframe, apply_keyframes_to_jobs from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, resolve_keyframe, apply_keyframes_to_jobs
from core.ffmpeg import build_audio_clip_command, build_crossfade_merge_command, build_audio_delete_command, build_audio_silence_command, build_audio_reverse_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,218 @@ 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")
def test_audio_edit_filters_empty_when_defaults():
from core.ffmpeg import audio_edit_filters
assert audio_edit_filters(duration=3.0) == []
def test_audio_edit_filters_fade_normalize_gain():
from core.ffmpeg import audio_edit_filters
f = audio_edit_filters(duration=10.0, fade_in=0.5, fade_out=2.0,
normalize=True, gain_db=-3.0)
assert "afade=t=in:st=0:d=0.5" in f
assert "afade=t=out:st=8.0:d=2.0" in f # fade-out starts at duration - fade_out
assert "loudnorm" in f
assert "volume=-3.0dB" in f
def test_audio_clip_command_no_filters_unchanged():
cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav")
assert "-af" not in cmd
def test_audio_clip_command_appends_filter_chain():
cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav",
filters=["afade=t=in:st=0:d=0.5", "loudnorm"])
i = cmd.index("-af")
assert cmd[i + 1] == "afade=t=in:st=0:d=0.5,loudnorm"
assert i < len(cmd) - 1 and cmd[-1] == "/o/a.wav"
def test_merge_single_clip_reencodes():
cmd = build_crossfade_merge_command(["/a.wav"], 0.5, "/o/out.mp3")
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
assert "libmp3lame" in cmd # codec by out ext
assert "acrossfade" not in " ".join(cmd)
assert "-map" not in cmd
assert "-filter_complex" not in cmd
assert cmd[-1] == "/o/out.mp3"
def test_merge_two_clips_acrossfade():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/out.wav")
assert cmd.count("-i") == 2
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]acrossfade=d=0.5" in fc
assert "[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
assert "pcm_s16le" in cmd # codec by out ext (multi-clip path)
def test_merge_three_clips_chains():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 1.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert fc.count("acrossfade=d=1.0") == 2 # two joins
assert "[0][1]acrossfade=d=1.0[a1]" in fc
assert "[a1][2]acrossfade=d=1.0[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
def test_merge_zero_crossfade_uses_concat():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "concat=n=2:v=0:a=1" in fc
assert "acrossfade" not in fc
def test_merge_empty_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command([], 0.5, "/o/o.wav")
def test_merge_per_join_crossfades():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], [0.5, 1.5], "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]acrossfade=d=0.5[a1]" in fc
assert "[a1][2]acrossfade=d=1.5[out]" in fc
def test_merge_curve_emitted_when_non_default():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/o.wav", curves="exp")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.5:c1=exp:c2=exp[out]" in fc
def test_merge_default_curve_omits_c1c2():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/o.wav") # tri default
fc = cmd[cmd.index("-filter_complex") + 1]
assert "c1=" not in fc # tri is ffmpeg's default -> omit
def test_merge_per_join_curves_list():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 0.5, "/o/o.wav",
curves=["tri", "exp"])
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]acrossfade=d=0.5[a1]" in fc # tri -> no suffix
assert "acrossfade=d=0.5:c1=exp:c2=exp[out]" in fc # exp on 2nd join
def test_merge_mixed_zero_crossfade():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], [0.0, 0.5], "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "[0][1]concat=n=2:v=0:a=1[a1]" in fc # butt-join 1st
assert "acrossfade=d=0.5" in fc # crossfade 2nd
def test_merge_crossfade_list_length_mismatch_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], [0.5], "/o/o.wav")
with pytest.raises(ValueError):
build_crossfade_merge_command(["/a.wav", "/b.wav"], [0.5, 0.5], "/o/o.wav")
def test_merge_curves_list_length_mismatch_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 0.5, "/o/o.wav", curves=["exp"])
def test_merge_unknown_curve_raises():
import pytest
with pytest.raises(ValueError):
build_crossfade_merge_command(["/a.wav", "/b.wav"], 0.5, "/o/o.wav", curves="bogus")
def test_merge_three_clips_zero_concat_chain():
cmd = build_crossfade_merge_command(["/a.wav", "/b.wav", "/c.wav"], 0.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert fc.count("concat=n=2:v=0:a=1") == 2
assert "acrossfade" not in fc
# --- destructive audio region ops (delete/silence/reverse) ---
def test_audio_delete_command():
cmd = build_audio_delete_command("/in.wav", 1.0, 3.0, "/o/o.wav")
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=1.0" in fc # keep [0,1]
assert "atrim=start=3.0" in fc # keep [3,end]
assert "concat=n=2:v=0:a=1[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
assert "pcm_s16le" in cmd # codec by ext
assert cmd[-1] == "/o/o.wav"
def test_audio_silence_command():
cmd = build_audio_silence_command("/in.wav", 1.0, 3.0, "/o/o.mp3")
af = cmd[cmd.index("-af") + 1]
# volume=0 gated to the region; commas inside between() are escaped for the filtergraph
assert af == "volume=0:enable='between(t\\,1.0\\,3.0)'"
assert "libmp3lame" in cmd
assert cmd[-1] == "/o/o.mp3"
def test_audio_reverse_command():
cmd = build_audio_reverse_command("/in.wav", 1.0, 3.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=1.0" in fc # head [0,1]
assert "atrim=start=1.0:end=3.0" in fc # middle [1,3]
assert "areverse" in fc # reversed middle
assert "atrim=start=3.0" in fc # tail [3,end]
assert "concat=n=3:v=0:a=1[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
def test_audio_delete_empty_head():
# deleting from 0 still produces a valid 2-branch concat (head is empty but harmless)
cmd = build_audio_delete_command("/in.wav", 0.0, 2.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=0.0" in fc and "atrim=start=2.0" in fc
def test_audio_heal_delete_command_crossfades_join():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 2.0, 4.0, "/o/o.wav", crossfade=0.1)
assert cmd[0] == "ffmpeg"
assert cmd.count("-i") == 1
fc = cmd[cmd.index("-filter_complex") + 1]
assert "atrim=end=2.0" in fc
assert "atrim=start=4.0" in fc
assert "acrossfade=d=0.1:c1=qsin:c2=qsin[out]" in fc
assert cmd[cmd.index("-map") + 1] == "[out]"
assert "pcm_s16le" in cmd
assert cmd[-1] == "/o/o.wav"
def test_audio_heal_delete_command_auto_crossfade_clamped():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 10.0, 12.0, "/o/o.mp3")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.25:c1=qsin:c2=qsin[out]" in fc
assert "libmp3lame" in cmd
def test_audio_heal_delete_command_near_start_shortens_crossfade():
from core.ffmpeg import build_audio_heal_delete_command
cmd = build_audio_heal_delete_command("/in.wav", 0.03, 1.0, "/o/o.wav")
fc = cmd[cmd.index("-filter_complex") + 1]
assert "acrossfade=d=0.03:c1=qsin:c2=qsin[out]" in fc
def test_audio_heal_delete_command_rejects_invalid_region():
import pytest
from core.ffmpeg import build_audio_heal_delete_command
with pytest.raises(ValueError):
build_audio_heal_delete_command("/in.wav", 3.0, 3.0, "/o/o.wav")
# --- ProcessedDB --- # --- ProcessedDB ---
def test_db_add_and_get_markers(): def test_db_add_and_get_markers():
@@ -439,3 +652,102 @@ 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)
# --- waveform peak reduction + region decode ---
def test_peaks_downsamples_to_bucket_count():
from core.waveform import peaks
import numpy as np
samples = np.sin(np.linspace(0, 100, 10000)).astype("float32")
p = peaks(samples, buckets=64)
assert len(p) == 64
assert all(0.0 <= v <= 1.0 for v in p)
import pytest
assert max(p) == pytest.approx(1.0)
def test_peaks_empty_returns_zeros():
from core.waveform import peaks
assert peaks(None, buckets=16) == [0.0] * 16
import numpy as np
assert peaks(np.zeros(0, dtype="float32"), buckets=8) == [0.0] * 8
def test_load_region_samples_bad_path_returns_empty():
from core.waveform import load_region_samples
import numpy as np
out = load_region_samples("/no/such/file.mp4", 0.0, 1.0)
assert isinstance(out, np.ndarray)
assert out.size == 0
def test_time_pixel_roundtrip():
from core.waveform import t_to_x, x_to_t
assert x_to_t(0, 400, 10.0, 4.0) == 10.0
assert x_to_t(400, 400, 10.0, 4.0) == 14.0
assert x_to_t(200, 400, 10.0, 4.0) == 12.0
assert t_to_x(12.0, 400, 10.0, 4.0) == 200
for x in (0, 37, 200, 399):
assert abs(t_to_x(x_to_t(x, 400, 10.0, 4.0), 400, 10.0, 4.0) - x) <= 1
def test_time_pixel_guards():
from core.waveform import t_to_x, x_to_t
assert x_to_t(50, 0, 10.0, 4.0) == 10.0 # zero width -> view_start
assert x_to_t(50, 400, 10.0, 0.0) == 10.0 # zero span -> view_start
assert t_to_x(12.0, 400, 10.0, 0.0) == 0 # zero span -> 0