130 Commits

Author SHA1 Message Date
Ethanfel c5dd2d00a0 fix: use noarchive mode and enable console to debug PYZ TOC error
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Bypasses PYZ archive entirely — modules stored as individual .pyc files.
Console enabled temporarily to capture error output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 23:12:58 +02:00
Ethanfel 34d8ad1dc7 feat: add Windows setup script and launcher for running from source
- setup-windows.ps1: downloads libmpv DLL and ffmpeg, installs pip deps
- 8cut.bat: double-click launcher
- main.py: add_dll_directory for libmpv next to script (not just frozen)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 23:08:17 +02:00
Ethanfel 46bd617f0a fix: disable UPX compression to prevent PYZ archive corruption
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
UPX can corrupt Python bytecode in PyInstaller bundles, causing
"PYZ archive entry not found in the TOC" on Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 23:06:30 +02:00
Ethanfel e8ecfc0525 ci: publish release automatically, re-enable macOS build
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:59:29 +02:00
Ethanfel 198ec68382 ci: temporarily disable macOS job while debugging Windows build
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:49:08 +02:00
Ethanfel 920f724dbd fix: find libmpv asset by name pattern instead of constructing URL
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Asset filenames include a git hash that can't be predicted from the tag
alone. Use the API assets list to find the correct download URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:48:08 +02:00
Ethanfel 94ea4c63ca fix: use GitHub API to fetch latest libmpv tag instead of redirect
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Invoke-WebRequest fails on 302 redirects in newer PowerShell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:45:44 +02:00
Ethanfel 653e4a5e13 refactor: split Windows and macOS into separate jobs
Build & Release / windows (push) Has been cancelled
Build & Release / macos (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Release is created if at least one platform succeeds, so a failure
on one doesn't block the other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:44:13 +02:00
Ethanfel cd50b3ae0c fix: quote PowerShell path interpolation in ffmpeg copy step
Build & Release / build (8cut-macos-arm64, macos-latest) (push) Has been cancelled
Build & Release / build (8cut-windows, windows-latest) (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:43:03 +02:00
Ethanfel 10b77e79f7 fix: drop macos-13 runner (no longer available)
Build & Release / build (8cut-macos-arm64, macos-latest) (push) Has been cancelled
Build & Release / build (8cut-windows, windows-latest) (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:42:36 +02:00
Ethanfel 5b4e4bf818 feat: add PyInstaller spec and GitHub Actions release workflow
Build & Release / build (8cut-macos-arm64, macos-latest) (push) Has been cancelled
Build & Release / build (8cut-macos-x86_64, macos-13) (push) Has been cancelled
Build & Release / build (8cut-windows, windows-latest) (push) Has been cancelled
Build & Release / release (push) Has been cancelled
Enables cross-platform builds for Windows and macOS. Adds _bin() helper
to resolve bundled ffmpeg in frozen builds, and configures ctypes library
path for bundled libmpv.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:40:44 +02:00
Ethanfel bd4e97c45a fix: lock mode seek falls back to cursor instead of jumping to start
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 19:15:01 +02:00
Ethanfel 1aeaad7f6d fix: skip keyframe creation at frame 0 where base state applies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:56:48 +02:00
Ethanfel 874632dffa fix: keep export complete message visible until next action
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:52:37 +02:00
Ethanfel 86055f2072 fix: defer preview follow so geometry is up-to-date after main window move
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:51:27 +02:00
Ethanfel 5fddb06354 fix: add right margin to panel, make Hide exported a QPushButton
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:48:51 +02:00
Ethanfel e60263548d feat: move status messages to inline label on settings row
Replace the bottom status bar with a right-aligned label on the
settings row, saving vertical space. Add "Export complete" message
when a batch finishes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:45:10 +02:00
Ethanfel 86f447f3d6 feat: add Show Hidden button to reveal and unhide playlist files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:39:22 +02:00
Ethanfel 1d5b8023a2 feat: auto-create/remove keyframes when toggling random crop in lock mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:29:11 +02:00
Ethanfel cb4392125d fix: scrub preview fallback before first keyframe + document overwrite behavior
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:08:35 +02:00
Ethanfel 328c800d60 feat: apply keyframe crop modes in overwrite exports too
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:05:20 +02:00
Ethanfel 7a35e8268b feat: preview effective keyframe crop state during lock-mode scrub
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:05:08 +02:00
Ethanfel 465894af51 feat: color-code keyframe diamonds by crop mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 16:04:53 +02:00
Ethanfel 1004bd0a28 feat: rewrite export to use per-keyframe crop modes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:58:01 +02:00
Ethanfel 279aee14cb feat: add apply_keyframes_to_jobs helper
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:57:27 +02:00
Ethanfel 4f15f77175 feat: snapshot ratio and random flags into crop keyframes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:52:25 +02:00
Ethanfel 17e42c44b3 refactor: widen keyframe tuple to carry ratio and random flags
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:52:04 +02:00
Ethanfel 8e8c8b9774 feat: add resolve_keyframe helper to extract sorted-keyframe lookup
Adds a pure function that returns the latest keyframe at or before a
given time (with tolerance), replacing the inline lookup pattern that
appears multiple times in main.py. Includes 6 tests covering empty
list, before-first, exact match, between, after-last, and tolerance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:48:00 +02:00
Ethanfel b9e9fa927e chore: add .worktrees/ to .gitignore 2026-04-14 15:45:26 +02:00
Ethanfel 5916b498b1 docs: add keyframe crop modes implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:30:46 +02:00
Ethanfel bda423e8b5 docs: add keyframe crop modes design
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:28:40 +02:00
Ethanfel 7b569dd248 docs: fix README to match current codebase
Remove fuzzy matching references (feature was removed), correct
test count (54 → 46), clarify that category is only saved to the
database not dataset.json, and add missing G and ?/F1 shortcuts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 12:58:53 +02:00
Ethanfel 3a2fd3d50b docs: add segment diagram SVG to README overview
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 12:36:14 +02:00
Ethanfel cfc0cb2f09 feat: multi-select in playlist for batch hide/remove
Extended selection mode (Ctrl+click, Shift+click) enabled. Right-click
context menu adapts to selection count — hide or remove multiple files
at once. Single click without modifiers still loads the file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 09:09:42 +02:00
Ethanfel d87b3c6da5 fix: disengage lock and clear keyframes when switching clips
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 08:53:33 +02:00
Ethanfel 39e7b19bc5 fix: rewrite playlist to never use hidden items, eliminating scroll bugs
Instead of hiding QListWidget items (which causes Qt to miscalculate
scroll position), the playlist now rebuilds its contents from scratch
whenever visibility changes. Only visible items exist in the widget.

- _rebuild() clears and repopulates from _paths filtered by visibility
- mark_done/unmark_done update in-place for visible items
- set_hidden_basenames and set_hide_exported trigger _rebuild
- Removes _LockedScrollBar, all scroll hacks, and workarounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 00:09:07 +02:00
Ethanfel ab5c8ae3db fix: scroll playlist to top after session resume
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 00:05:56 +02:00
Ethanfel dcd4a6aace fix: locked scrollbar snaps to top instead of drifting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 00:05:08 +02:00
Ethanfel 96d4dd8d89 fix: replace scrollTo override with locked scrollbar to block all scroll
_LockedScrollBar subclasses QScrollBar and blocks setValue when locked.
This catches ALL scroll sources — Qt layout, auto-scroll, item changes —
not just scrollTo. Locked during setCurrentRow, visibility changes,
playlist checks, and video load (200ms after load to catch late events).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 00:00:58 +02:00
Ethanfel a6b91d9d3f fix: crop keyframes and position apply to random portrait/square exports
Keyframes were skipped when ratio was None (random mode), and random
crop was overwriting the resolved center with base_center. Now
keyframes resolve the center for all jobs first, then random crop
assigns the ratio while preserving the per-job center.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:55:06 +02:00
Ethanfel 12b06e8144 feat: cancel button to abort running exports
ExportWorker now uses Popen instead of subprocess.run so running
ffmpeg processes can be killed on cancel. Cancel button appears
between Export and worker count spinner, enabled during export.
Clips already finished before cancel are kept in the DB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:49:44 +02:00
Ethanfel 4c3b3fb2db fix: override scrollTo to block Qt auto-scroll during playlist operations
Replace timer hacks and scrollbar save/restore with a proper fix:
PlaylistWidget.scrollTo() is overridden to no-op when _scroll_locked
is set. Lock is held during setCurrentRow, visibility changes,
playlist checks, and video load — all operations where Qt's internal
auto-scroll was causing the list to jump.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:47:52 +02:00
Ethanfel 89d6feee47 fix: restore playlist scroll at 0/50/150ms to catch late layout events
mpv video surface resize triggers layout events across multiple event
loop cycles. A single deferred restore was too early.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:45:05 +02:00
Ethanfel 7051cc5b93 fix: defer playlist scroll restore to next event loop iteration
Qt processes layout changes asynchronously, so restoring the scrollbar
immediately after _after_load was too early. Use QTimer.singleShot(0)
to restore after Qt finishes processing pending layout events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:44:03 +02:00
Ethanfel 7c776e24af fix: stash playlist scroll before video load, restore after
Video load triggers layout changes (buttons, crop bar, preview window)
that cause Qt to recalculate the playlist scrollbar position. Now saves
the scroll value in _load_file and restores it at the end of _after_load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:42:55 +02:00
Ethanfel 04e78eb355 fix: remove scrollToItem that was overriding saved scroll position
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:41:19 +02:00
Ethanfel 97986d5138 fix: save/restore scrollbar position to prevent playlist scroll jumping
setCurrentRow, setHidden, and setText all trigger Qt internal scroll
recalculation. Now save scrollbar value before these operations and
restore it after, then use scrollToItem only to bring off-screen
selections into view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:40:17 +02:00
Ethanfel 3c903c7188 feat: right-click keyframe diamond on timeline to delete it
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:11:32 +02:00
Ethanfel bef08be091 fix: crop keyframe in lock mode updates video overlay and preview lines
The lock-mode path was only updating the crop bar but not the video
overlay or random overlays. Now sets _crop_center and calls the
appropriate overlay update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:10:02 +02:00
Ethanfel ccc94ccb5c fix: preview crop lines match main overlay colors
Portrait lines are red, square lines are blue — matching the video
overlay. Both shown simultaneously when both random options are on.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:07:07 +02:00
Ethanfel d031d6c285 fix: hide-exported checkbox state applied on session resume
_apply_playlist_filters now syncs _hide_exported from checkbox before
refreshing visibility, so exported files are hidden immediately on
startup without needing to toggle the checkbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:03:08 +02:00
Ethanfel 9696b94b0c fix: preview crop shows lines only (no dim), updates on random toggle
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:00:44 +02:00
Ethanfel 633e421a68 feat: show crop region overlay on end-frame preview
PreviewLabel widget replaces plain QLabel, draws dimmed areas outside
the crop window and blue border lines matching the crop bar position.
Updates live when portrait ratio or crop center changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 16:58:31 +02:00
Ethanfel a543c72ff5 fix: prevent scroll jumping by batching visibility and layout updates
- _apply_visibility wraps setHidden loop with setUpdatesEnabled and
  scrolls back to current item after
- _refresh_playlist_checks wraps mark_done/unmark_done loop similarly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 16:55:21 +02:00
Ethanfel 31772b898c fix: hidden files reappear on restart, scroll jumps on selection
- Centralize item visibility in _apply_visibility: hidden if
  profile-hidden OR (hide_exported AND done)
- mark_done/unmark_done no longer touch setHidden directly
- Session resume selects first visible item after filters applied
- add_files defers to _select_first_visible to skip hidden items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 16:53:17 +02:00
Ethanfel 9b8d742fde fix: scroll to selected item to prevent list jumping on click
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 16:49:01 +02:00
Ethanfel 8392c022f6 feat: session resume, hide exported files, profile-based file hiding
- Resume last session: reload previous playlist files on startup
- Hide exported checkbox: filter out files with existing clips
- Profile-based hiding: right-click → "Hide in profile" persists via DB
- Playlist scrollbar fix: disable updates during batch add
- Drop event and profile switch use unified _apply_playlist_filters()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 16:47:44 +02:00
Ethanfel d4357f0da4 feat: cursor lock with crop keyframing, remove fuzzy filename matching
- Lock button (G key) freezes export cursor, timeline scrubs playback only
- In lock mode, clicking crop bar sets a keyframe at current playback time
- Orange diamonds on timeline show keyframe positions
- Export resolves per-clip crop center from nearest preceding keyframe
- Crop bar/overlay updates while scrubbing to preview effective crop
- Unlocking clears all keyframes
- Replace fuzzy filename matching with exact match to prevent marker bleed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 16:34:49 +02:00
Ethanfel 2dcf9bc856 fix: preview window stays on top and raises with main window
- Add WindowStaysOnTopHint to keep preview above other windows
- Raise preview on main window activation (alt-tab, taskbar click)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 15:39:49 +02:00
Ethanfel 703874721b feat: YOLO subject tracking for per-clip crop centering, fix end-frame preview
- Track subject checkbox: auto-adjusts crop center per sub-clip using
  YOLOv8-nano detection on each start frame
- Detects target nearest to user's crop click, follows same class across clips
- Graceful fallback when ultralytics not installed or detection fails
- Fix end-frame preview not updating on clip/spread change (retry on busy grabber)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 15:12:17 +02:00
Ethanfel bd37938a4a fix: playlist shows clip count per file, export counter fills gaps after delete
- Playlist marks show [N] instead of ✓ to indicate how many clips exported
- Export counter scans from 1 to find first available slot instead of only advancing
- Refresh playlist checks and counter after marker/group deletion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 13:37:59 +02:00
Ethanfel e28af38743 fix: compact playlist rows by replacing word wrap with middle ellipsis
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 13:16:24 +02:00
Ethanfel 039d383cf6 fix: VAAPI filter chain, overwrite duplicates, export cursor stash, shutdown guards
- VAAPI: hwdownload before CPU filters, skip HW setup for image sequences
- Delete old DB rows before overwrite re-insert to prevent duplicates
- Stash cursor at export time so async completion uses correct position
- Restore crop_center on marker click, fix falsy 0-value checks
- Remove stale pending marker on export error
- Guard double mpv termination in closeEvent
- SnapPreviewWindow recursion guard for dock/follow moves

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 13:07:20 +02:00
Ethanfel e283d96417 feat: HW encode toggle, workers control, GPU decode, verbose logging, UI polish
- Add HW encode checkbox (auto-detects NVENC/VAAPI/QSV/AMF/VideoToolbox),
  caps GPU workers to 3 to avoid concurrent session limits
- Add workers spinbox next to Export button to control parallel ffmpeg count
- Enable mpv hwdec=auto for GPU-accelerated video decoding during playback
- Add timestamped terminal logging for startup, file load, export, errors
- Reorder settings row: video settings (Resize, Portrait) then encoding
  (Format, HW, Clips, Spread, random crops)
- Fix filename label wrapping by using QSizePolicy.Ignored + no word wrap
- Alternating row colors in playlist for readability
- Snap-to-edge preview window: docks to main window edges, follows on move
- Fix SEGV on exit: explicit mpv shutdown in MainWindow.closeEvent before
  Qt tears down QObjects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:56:00 +02:00
Ethanfel 1e99035393 feat: random square crop, shortcuts dialog, profile dropdown, video aspect fix
- Add "1 random square" checkbox (1:1 crop) alongside random portrait (9:16);
  both share the same ~1-per-3 quota when enabled together
- Multi-overlay system: portrait guides in red, square guides in blue, both
  visible simultaneously on the paused frame
- Replace editable profile QComboBox with non-editable dropdown + "New profile..."
  item via QInputDialog — fixes markers not updating on profile switch
- Refresh playlist checkmarks on profile switch (add/remove done marks)
- Add "?" button and ?/F1 shortcut to show keyboard shortcuts dialog
- Re-render video on widget resize to preserve aspect ratio
- Compute letterbox/pillarbox video rect for correct overlay + click mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:10:39 +02:00
Ethanfel 0e38c5666e fix: reset overwrite/delete state on profile switch
Switching profiles now clears the overwrite path, button states, and
next-label so stale state from the old profile doesn't persist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:22:20 +02:00
Ethanfel f9c5a42453 fix: make QComboBox dropdown arrow visible in dark theme
The previous border:none rule hid the dropdown indicator entirely,
making combos look like plain text fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:20:09 +02:00
Ethanfel f8b148f77d feat: profile support for independent marker sets
Each profile has its own set of timeline markers, so the same video
can be cut with different settings (e.g. landscape vs portrait) without
markers interfering. Profile selector in the top bar, persisted in
QSettings, stored per-row in the DB.

- Add `profile` column to ProcessedDB schema (migrates existing rows
  to 'default')
- Scope find_similar, get_markers, _get_markers_for by profile
- Add get_profiles() for populating the combo dropdown
- Thread profile through _DBWorker, _after_load, _refresh_markers,
  _on_clip_done, dropEvent, _on_open_files
- Editable profile QComboBox in top bar, refreshed after each export
- 5 new tests for profile isolation and backward compatibility (54 total)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 11:08:50 +02:00
Ethanfel 462af36bce docs: replace SELVA references with foley
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 03:01:38 +02:00
Ethanfel ae15f9ef32 docs: add social preview SVG/PNG and logo PNG
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 03:00:45 +02:00
Ethanfel 2ef387d87b docs: update README and add SVG logo
- Add timeline-style SVG banner with markers, playhead, and branding
- Rewrite README to reflect current features (batch export, SELVA
  annotation, group delete/overwrite, playlist, shortcuts)
- Remove outdated mask generation references
- Update test count to 49

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 02:58:47 +02:00
Ethanfel bcdda9c783 fix: UI audit — dark theme styling, group delete/overwrite, layout cleanup
- Style QComboBox/QSpinBox/QDoubleSpinBox/QCheckBox in dark theme
- Delete and overwrite now operate on the full clip group, not just one sub-clip
- Add get_group/delete_group to ProcessedDB with tests
- Restructure control rows: transport+actions / annotation+path / encoding
- Add "Open Files" button to queue panel (replaces drag-drop-only)
- Playlist right-click to remove items
- Compact time display (1:23.4 / 5:00.0), window title shows filename
- Short side: QLineEdit → QSpinBox with validation
- Tooltips with keyboard shortcuts on all interactive widgets
- Fix arrow hint direction, remove stale mask comment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 02:56:26 +02:00
Ethanfel e2b4f9bf8d remove: mask generation, venv setup, and settings dialog
Dead code — masking is handled externally via ComfyUI. Removes
SetupWorker, MaskWorker, SettingsDialog, build_mask_output_dir,
the mask UI row, Settings button, and associated test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:53:31 +02:00
Ethanfel bb6e3c623a fix: UX audit — shortcuts in text fields, delete confirmation, overwrite indicator
- Suppress global shortcuts (E/J/L/K/M/Space/P/arrows) when typing in
  text fields via ShortcutOverride event filter
- Add delete confirmation dialog before removing clips from disk + DB
- Export button turns red "Overwrite" when a marker is selected
- Reset stale overwrite/delete state when switching files
- Remove auto-advance after export; add N shortcut to advance manually
- Widen marker hit zones (±6→±10px click, ±4→±8px hover)
- Marker tooltip shows filename instead of full path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:49:27 +02:00
Ethanfel 89e0478777 feat: parallel export, playback position, double-click markers, reset clips on video switch
- Parallelize batch ffmpeg exports with ThreadPoolExecutor
- Show playback progress as color fill in timeline selection region
- Single click moves playhead, double-click selects/deselects markers
- Reset clip count and spread to defaults when switching videos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:42:40 +02:00
Ethanfel c287788d9e fix: marker click properly restores settings without cursor clear race
Emit marker_clicked before seek, and use a flag to prevent
_on_cursor_changed from immediately clearing the overwrite state
and settings that were just restored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 11:01:29 +02:00
Ethanfel 5a5961ae21 fix: default short_side to 512 in DB schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 02:55:09 +02:00
Ethanfel c174d891fb fix: migrate DB schema with ALTER TABLE instead of dropping old data
Legacy records get new columns with sensible defaults and are preserved
as markers. No more data loss on schema upgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 02:53:12 +02:00
Ethanfel 74e8656335 feat: save and restore all export config on marker click
DB now stores short_side, portrait_ratio, crop_center, format,
clip_count, and spread per export. Clicking a marker restores all
fields to the original export settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 02:52:02 +02:00
Ethanfel 206b95fc28 feat: restore label and category when clicking a marker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 02:49:48 +02:00
Ethanfel f11b3e298e feat: group clips into subfolders (clip_001/, clip_002/, etc.)
Each batch export creates a subfolder named after the group (e.g.
clip_001/) containing all sub-clips and their audio files. Keeps
the top-level export folder clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:05:10 +02:00
Ethanfel 22e2ad27a0 fix: show one marker per batch, not one per sub-clip
Pending marker uses cursor position for the whole batch. DB markers
deduplicated by start_time since all sub-clips share the same cursor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:41:41 +02:00
Ethanfel fee907f26f fix: raise clip count limit from 10 to 99
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:37:57 +02:00
Ethanfel 122f89547b feat: show crop bar with vertical lines when random portrait is enabled
When the random portrait checkbox is on and portrait dropdown is Off,
the crop bar appears and the video overlay shows 2 red vertical lines
(instead of filled red bands) indicating the 9:16 crop boundaries.
Clicking the crop bar or video adjusts the crop center position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:30:49 +02:00
Ethanfel b6e7b660a8 fix: scale end-frame preview to 320x240
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:18:48 +02:00
Ethanfel 2304286147 fix: make end-frame preview a floating tool window
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:55:43 +02:00
Ethanfel abe9e6ee66 feat: end-frame preview panel showing last frame of clip spread
Small panel to the right of the video displays the frame at cursor +
clip_span. Updated with 300ms debounce on cursor move, spread/clip
count change, and file load. Uses ffmpeg to grab a single PNG frame
off the main thread.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:52:56 +02:00
Ethanfel 01961e9192 fix: move QSettings init before timeline setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:44:46 +02:00
Ethanfel d3e48f5276 fix: random portrait uses configured crop position, only randomizes which clip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:43:16 +02:00
Ethanfel 0996670020 fix: random portrait always uses 9:16
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:40:47 +02:00
Ethanfel ffb99d3e66 fix: scale random portrait count — 1 per 3 clips (e.g. 2 for 6 clips)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:39:42 +02:00
Ethanfel 439bc85505 feat: random portrait — one clip per batch gets a random crop ratio + position
Checkbox '1 random portrait' in settings row. When checked, one random
clip in each batch receives a randomly chosen portrait ratio (9:16, 4:5,
or 1:1) with a random crop center. The rest use the global portrait
setting. Disabled for single-clip overwrite exports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:38:56 +02:00
Ethanfel 13973dd53d fix: read clip_count/spread from settings before timeline init
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:34:03 +02:00
Ethanfel 9e07910df1 feat: make clip count configurable (1–10, default 3)
Clips spinbox in settings row alongside Spread. Preview span and export
batch size adjust dynamically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:28:12 +02:00
Ethanfel 25250d6d8d fix: use lossless PCM audio in MP4 exports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:26:02 +02:00
Ethanfel 93cee40b06 feat: export 3 overlapping 8s clips per press with configurable spread
Each export generates clip_NNN_0, clip_NNN_1, clip_NNN_2 offset by the
spread value (2–8s, default 3s). Preview plays the full span covered by
all three clips. Marker click still overwrites a single clip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:22:41 +02:00
Ethanfel 47ea6199fa feat: add Delete button to remove exports from disk, DB, and dataset.json
Targets last export by default; targets marker-selected clip when one is
active (shown as "Delete <name>"). Clears on cursor move if no last export.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:23:16 +02:00
Ethanfel 8bc42cabd9 fix: store absolute paths in dataset.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:10:06 +02:00
Ethanfel 8148971aae refactor: remove fps from annotation — path + label only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:09:08 +02:00
Ethanfel a8d8bd7fdc Revert "fix: pass source fps to ffmpeg export via fps filter"
This reverts commit fc9287a5a6.
2026-04-07 14:08:00 +02:00
Ethanfel f0484fac86 Revert "feat: write fps.txt alongside WebP sequence audio"
This reverts commit cf62940b84.
2026-04-07 14:08:00 +02:00
Ethanfel fd25e8fa37 feat: add migration script to generate dataset.json from DB history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:04:29 +02:00
Ethanfel c3c480acc7 feat: replace dataset.tsv with dataset.json annotation file
Each exported clip writes an entry to <folder>/dataset.json containing
its relative path, sound label, and fps. Re-exporting to the same path
updates the existing entry (upsert). Empty labels are skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:01:50 +02:00
Ethanfel cf62940b84 feat: write fps.txt alongside WebP sequence audio 2026-04-07 13:56:13 +02:00
Ethanfel fc9287a5a6 fix: pass source fps to ffmpeg export via fps filter 2026-04-07 13:54:38 +02:00
Ethanfel 1256819dde ui: warn mask generation is untested, recommend ComfyUI 2026-04-07 12:35:44 +02:00
Ethanfel 58043dd7e3 docs: warn mask generation is untested, recommend ComfyUI 2026-04-07 12:35:13 +02:00
Ethanfel a652872d15 docs: add GPL v3 license and GitHub source link 2026-04-07 12:34:13 +02:00
Ethanfel 9d7791557b revert: restore ff0cd00 (label history, overwrite mode, crop overlay)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:11:36 +02:00
Ethanfel 2352d530d0 feat: queue done marks, export UX, scrub-while-playing, counter fix
- Queue ✓: mark files green after export and on drop if already in DB
- Export folder auto-created if missing
- Scrub while playing: cursor drag seeks and continues playback
- Counter auto-advances past existing files on disk
- Instant pending marker on timeline when export starts
- WebP quality 92 + lanczos scaling
- seek() guard against unloaded state (SystemError fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:41:07 +02:00
Ethanfel 6e02a08046 Revert "feat: label history, overwrite mode, crop overlay, UX improvements"
This reverts commit ff0cd00309.
2026-04-07 11:36:40 +02:00
Ethanfel ff0cd00309 feat: label history, overwrite mode, crop overlay, UX improvements
- Editable label combo with DB history (most recent first)
- Click marker to arm overwrite mode (↺ indicator, re-exports same file)
- Portrait crop overlay: red bands on cut regions (paused only)
- Cache video dimensions to avoid mpv property reads in paintEvent
- Queue marks done files with ✓ on drop and after export
- Export folder created automatically if missing
- Play continues from new position when scrubbing during playback
- Counter auto-advances past existing files on disk
- Instant marker on timeline when export starts (optimistic)
- Marker right-click delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:34:47 +02:00
Ethanfel 6573fa6e05 feat: mpv Wayland embedding, timeline redesign, UX polish
mpv embedding:
- Replace wid/QOpenGLWidget with QOffscreenSurface + QOpenGLFramebufferObject
  + QPainter readback — works on Wayland/KDE without sub-surface compositing
- Force desktop OpenGL 3.3 core profile before QApplication (fixes black output on GLES)
- Timer-based render polling (16 ms) replaces signal-flood from mpv C thread;
  fixes playback animation and scrubbing preview
- Fix AB-loop: set ab-loop-a/b to "no" on stop (0 means infinite in mpv)

Timeline:
- Full redesign: time ruler with adaptive major/minor ticks, playhead triangle
  handle, selection region with edge lines, numbered marker badges
- Height 160 px; layout collapsed from 4 rows to 2 below timeline
- Markers appear immediately on export (optimistic update before ffmpeg finishes)
- Right-click marker → context menu to delete from DB

Hotkeys:
- Replace keyPressEvent with QShortcut(ApplicationShortcut) so keys work
  regardless of focused widget; MpvWidget gets NoFocus policy

Export:
- WebP: switch lossless→lossy quality 85, compression_level 1 (~10x faster)
- Add -threads 0 for full CPU utilisation during decode/filter
- Remember last export folder across sessions via QSettings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:22:58 +02:00
Ethanfel 7931a0e3db debug: write mpv log to /tmp/8cut-mpv.log 2026-04-06 20:56:14 +02:00
Ethanfel 939199717f fix: use QOpenGLContext.getProcAddress for mpv render context proc lookup 2026-04-06 20:54:19 +02:00
Ethanfel 8863634bce debug: add mpv log output and initializeGL trace 2026-04-06 20:51:11 +02:00
Ethanfel e5f2c732d9 fix: wrap get_proc_address as CFUNCTYPE for mpv render context 2026-04-06 20:50:02 +02:00
Ethanfel 1e0c1ae892 fix: replace wid embedding with OpenGL render context — works on Wayland and X11 2026-04-06 20:48:59 +02:00
Ethanfel 2c95c2618a debug: print platform/wid/WAYLAND_DISPLAY at mpv init 2026-04-06 20:41:55 +02:00
Ethanfel c130010e36 fix: hide WAYLAND_DISPLAY during mpv init so it uses X11 wid embedding 2026-04-06 20:41:16 +02:00
Ethanfel 02c63bdfde fix: force QT_QPA_PLATFORM=xcb so mpv embeds correctly on Wayland sessions 2026-04-06 20:39:12 +02:00
Ethanfel f30955b63c debug: print drag-drop events to diagnose Wayland DnD 2026-04-06 20:37:13 +02:00
Ethanfel c80ebf9383 fix: move drag-drop to MainWindow to bypass QListWidget viewport on Wayland 2026-04-06 20:35:47 +02:00
Ethanfel 3d92a66660 fix: re-apply LC_NUMERIC=C after QApplication resets locale 2026-04-06 20:33:51 +02:00
Ethanfel bdfb9354ca fix: set LC_NUMERIC=C before mpv init to prevent segfault on non-C locales 2026-04-06 20:32:56 +02:00
Ethanfel 41c36267e9 fix: use NoDragDrop mode + viewport.setAcceptDrops for Wayland/KDE compatibility 2026-04-06 20:31:37 +02:00
Ethanfel d720fc0541 fix: accept dragMoveEvent so file drops work on all desktop environments 2026-04-06 20:30:31 +02:00
Ethanfel 31f4df4c5b fix: suppress QFrame paintEvent on mpv native window 2026-04-06 20:29:06 +02:00
Ethanfel 39bbfedb9d fix: suppress paintEngine warnings from mpv native window embedding 2026-04-06 20:28:00 +02:00
Ethanfel 025d5afe1f chore: add shebang for direct execution 2026-04-06 20:25:07 +02:00
24 changed files with 5924 additions and 897 deletions
+115
View File
@@ -0,0 +1,115 @@
name: Build & Release
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
jobs:
# ── Windows ────────────────────────────────────────────────
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Python dependencies
run: pip install pyinstaller PyQt6 python-mpv
- name: Fetch ffmpeg
shell: pwsh
run: |
$ffUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
Invoke-WebRequest $ffUrl -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg-tmp
$bin = Get-ChildItem -Path ffmpeg-tmp -Recurse -Filter ffmpeg.exe | Select-Object -First 1
Copy-Item "$($bin.DirectoryName)\ffmpeg.exe" .
Copy-Item "$($bin.DirectoryName)\ffprobe.exe" .
- name: Fetch libmpv
shell: pwsh
run: |
$release = Invoke-RestMethod "https://api.github.com/repos/shinchiro/mpv-winbuild-cmake/releases/latest"
$asset = $release.assets | Where-Object { $_.name -like "mpv-dev-x86_64-v3-*" } | Select-Object -First 1
Invoke-WebRequest $asset.browser_download_url -OutFile mpv-dev.7z
7z x mpv-dev.7z -ompv-dev
Copy-Item mpv-dev\libmpv-2.dll .
- name: Build with PyInstaller
run: pyinstaller 8cut.spec
- name: Package
shell: pwsh
run: Compress-Archive -Path dist\8cut\* -DestinationPath 8cut-windows.zip
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: 8cut-windows
path: 8cut-windows.zip
# ── macOS (Apple Silicon) ──────────────────────────────────
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Python dependencies
run: pip install pyinstaller PyQt6 python-mpv
- name: Install native deps
run: |
brew install mpv ffmpeg
cp "$(brew --prefix mpv)/lib/libmpv.2.dylib" .
cp "$(brew --prefix ffmpeg)/bin/ffmpeg" .
cp "$(brew --prefix ffmpeg)/bin/ffprobe" .
- name: Build with PyInstaller
run: pyinstaller 8cut.spec
- name: Fix dylib rpaths
run: |
DYLIB="dist/8cut/libmpv.2.dylib"
if [ -f "$DYLIB" ]; then
install_name_tool -id @executable_path/libmpv.2.dylib "$DYLIB"
fi
- name: Package
run: |
cd dist
zip -r ../8cut-macos-arm64.zip 8cut.app
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: 8cut-macos-arm64
path: 8cut-macos-arm64.zip
# ── Create GitHub Release ──────────────────────────────────
release:
needs: [windows, macos]
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') && (needs.windows.result == 'success' || needs.macos.result == 'success') }}
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create Release
uses: softprops/action-gh-release@v2
with:
draft: false
generate_release_notes: true
files: artifacts/**/*.zip
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.pyc
*.pyo
.pytest_cache/
.worktrees/
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
python main.py %*
+142
View File
@@ -0,0 +1,142 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec for 8-cut.
Usage:
pyinstaller 8cut.spec
Platform-specific notes:
Windows: place libmpv-2.dll, ffmpeg.exe, ffprobe.exe next to main.py
before building, or set FFMPEG_DIR / MPV_DIR env vars.
macOS: place libmpv.2.dylib, ffmpeg, ffprobe next to main.py
before building, or set FFMPEG_DIR / MPV_DIR env vars.
Linux: system libmpv and ffmpeg are used from PATH (not bundled).
"""
import os
import platform
import sys
from pathlib import Path
block_cipher = None
system = platform.system()
# ---------- paths ----------------------------------------------------------
base = Path(SPECPATH)
ffmpeg_dir = Path(os.environ.get("FFMPEG_DIR", base))
mpv_dir = Path(os.environ.get("MPV_DIR", base))
# ---------- data files -----------------------------------------------------
datas = []
# YOLOv8 model (optional — large, skip if missing)
yolo = base / "yolov8n.pt"
if yolo.exists():
datas.append((str(yolo), "."))
# ---------- native binaries ------------------------------------------------
binaries = []
if system == "Windows":
for name in ("libmpv-2.dll",):
p = mpv_dir / name
if p.exists():
binaries.append((str(p), "."))
for name in ("ffmpeg.exe", "ffprobe.exe"):
p = ffmpeg_dir / name
if p.exists():
binaries.append((str(p), "."))
elif system == "Darwin":
for name in ("libmpv.2.dylib", "libmpv.dylib"):
p = mpv_dir / name
if p.exists():
binaries.append((str(p), "."))
break
for name in ("ffmpeg", "ffprobe"):
p = ffmpeg_dir / name
if p.exists():
binaries.append((str(p), "."))
# ---------- analysis -------------------------------------------------------
a = Analysis(
[str(base / "main.py")],
pathex=[str(base)],
binaries=binaries,
datas=datas,
hiddenimports=[
"mpv",
"PyQt6.QtOpenGL",
"PyQt6.QtOpenGLWidgets",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
# ultralytics is optional and huge — exclude from frozen build
"ultralytics",
"torch",
"torchvision",
"onnxruntime",
"opencv-python",
# test / dev
"pytest",
"hypothesis",
],
noarchive=True,
cipher=block_cipher,
)
pyz = PYZ(a.pure, cipher=block_cipher)
# ---------- executable -----------------------------------------------------
exe_kwargs = dict(
pyz=pyz,
a=a,
name="8cut",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True, # temporary: show errors on launch
)
if system == "Darwin":
exe_kwargs["icon"] = str(base / "assets" / "logo.png")
elif system == "Windows":
ico = base / "assets" / "logo.ico"
if ico.exists():
exe_kwargs["icon"] = str(ico)
exe = EXE(**exe_kwargs)
# ---------- collect --------------------------------------------------------
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name="8cut",
)
# ---------- macOS .app bundle (only on Darwin) -----------------------------
if system == "Darwin":
app = BUNDLE(
coll,
name="8cut.app",
icon=str(base / "assets" / "logo.png"),
bundle_identifier="com.8cut.app",
info_plist={
"CFBundleDisplayName": "8cut",
"CFBundleShortVersionString": "1.0.0",
"NSHighResolutionCapable": True,
"LSMinimumSystemVersion": "11.0",
},
)
+14
View File
@@ -0,0 +1,14 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
For the precise terms and conditions for copying, distribution and
modification, see https://www.gnu.org/licenses/gpl-3.0.txt
+94 -50
View File
@@ -1,28 +1,63 @@
# 8-cut
A desktop tool for cutting 8-second clips from video files, designed for building [SELVA](https://github.com/google-deepmind/selva) datasets.
<p align="center">
<img src="assets/logo.svg" alt="8-cut — 8-second clips for foley datasets" width="720">
</p>
<p align="center">
<a href="https://github.com/ethanfel/8-cut/blob/master/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-blue.svg" alt="License: GPL v3"></a>
</p>
A desktop tool for cutting 8-second clips from video files, designed for building foley datasets.
## Overview
8-cut lets you scrub through a video, mark a cut point, and export a fixed 8-second clip with one keypress. It tracks every export in a local SQLite database so you can resume a session or switch between resolution variants of the same source without duplicating work.
8-cut lets you scrub through a video, mark a cut point, and export a batch of overlapping 8-second clips with one keypress. It tracks every export in a local SQLite database so you can resume a session without duplicating work.
All clips are exactly 8 seconds — this is a hard constraint of the SELVA format.
All clips are exactly 8 seconds — the standard length for foley sound datasets.
<p align="center">
<img src="assets/segment-diagram.svg" alt="Batch export diagram — 3 overlapping 8-second clips offset by a 3-second spread" width="720">
</p>
## Features
- **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/K/L for frame-by-frame stepping
- **Keyboard shortcuts** — J/L step one frame, Shift+J/L step one second, Space/P play/pause, K pause and return to cursor, E export, M jump to next marker
- **Two export formats** — H.264/AAC MP4 or lossless WebP image sequence (frames + `.wav` audio extracted alongside)
- **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; adjustable horizontal crop position
- **Resize** — scale short side to a fixed pixel size (e.g. 256)
- **Export history** — timeline markers show previously exported clips; fuzzy filename matching detects resolution variants of the same file (e.g. `_2160p` vs `_1080p`)
- **Mask generation** — generate binary foreground masks per-frame using SAM2 (segmentation) or Depth Anything V2 (depth-based), via a bundled venv
- **Playlist** — drag-and-drop multiple files; duplicates are ignored
- **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/L for frame-by-frame, Shift for 1-second steps
- **Batch export** — export multiple overlapping clips per cut point with configurable count and spread offset
- **Two export formats** — H.264 MP4 with lossless PCM audio, or WebP image sequence (frames + `.wav`)
- **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; click the video or crop bar to reposition
- **Random portrait** — optionally apply a random portrait crop to a subset of each batch
- **Resize** — scale short side to a fixed pixel size (e.g. 512)
- **Sound annotation** — label and category fields saved to the clip database; label also written to `dataset.json`
- **Export history** — timeline markers show previously exported clips; double-click to enter overwrite mode; right-click to delete
- **End-frame preview** — floating window shows the last frame of the selection region
- **Playlist** — drag-and-drop or use the Open Files button; right-click to remove items
- **Playback loop** — plays the exact selection region on loop so you can preview what will be exported
- **Group operations** — delete or overwrite acts on all sub-clips in a batch, not just one
- **Profiles** — switch between independent marker sets (e.g. "landscape" vs "portrait") for the same video
## Keyboard shortcuts
| Key | Action |
|-----|--------|
| `Left` / `J` | Step back 1 frame |
| `Right` / `L` | Step forward 1 frame |
| `Shift+Left` / `Shift+J` | Step back 1 second |
| `Shift+Right` / `Shift+L` | Step forward 1 second |
| `Space` / `P` | Toggle play/pause |
| `K` | Pause and snap to cursor |
| `E` | Export |
| `M` | Jump to next marker (wraps) |
| `N` | Next file in playlist |
| `G` | Toggle cursor lock |
| `?` / `F1` | Show keyboard shortcuts |
Shortcuts are suppressed when a text field has focus.
## Requirements
- Python 3.11+
- `ffmpeg` in `PATH`
- `ffmpeg` on `PATH`
- PyQt6
- python-mpv (requires libmpv)
@@ -30,15 +65,15 @@ All clips are exactly 8 seconds — this is a hard constraint of the SELVA forma
pip install -r requirements.txt
```
For mask generation tools, additional dependencies (PyTorch, transformers, segment-anything-2, opencv) are installed into `~/.8cut/venv/` via the Settings dialog.
### Platform notes
**Linux** — install libmpv via your package manager (`apt install libmpv-dev` / `pacman -S mpv`).
| Platform | libmpv |
|----------|--------|
| **Linux** | `apt install libmpv-dev` or `pacman -S mpv` |
| **macOS** | `brew install mpv` |
| **Windows** | Download `mpv-2.dll` from [mpv Windows builds](https://sourceforge.net/projects/mpv-player-windows/files/libmpv/) and place it in `PATH` or next to `main.py` |
**macOS** — install libmpv via Homebrew: `brew install mpv`.
**Windows**`python-mpv` requires `mpv-2.dll` in `PATH` or in the same directory as `main.py`. Download it from the [mpv Windows builds](https://sourceforge.net/projects/mpv-player-windows/files/libmpv/) page (pick the latest `mpv-dev-x86_64-*.7z`, extract `mpv-2.dll`). Also ensure `ffmpeg.exe` is in `PATH` (e.g. via [winget](https://winget.run/): `winget install ffmpeg`).
Windows also needs `ffmpeg.exe` on `PATH` (e.g. `winget install ffmpeg`).
## Usage
@@ -46,47 +81,52 @@ For mask generation tools, additional dependencies (PyTorch, transformers, segme
python main.py
```
Drop a video onto the playlist or use the file picker. Scrub to your cut point, set the output folder and clip name, then press **Export** (or `E`).
Drop videos onto the queue or click **+ Open Files**. Scrub to your cut point, then press **Export** (or `E`).
### Export formats
### Export layout
| Format | Output |
|--------|--------|
| MP4 | `<folder>/<name>_NNN.mp4` — H.264 video + AAC audio |
| WebP sequence | `<folder>/<name>_NNN/frame_%04d.webp` — lossless WebP frames + `<name>_NNN.wav` PCM audio |
### Keyboard shortcuts
| Key | Action |
|-----|--------|
| `←` / `J` | Step back 1 frame |
| `→` / `L` | Step forward 1 frame |
| `Shift+←` / `Shift+J` | Step back 1 second |
| `Shift+→` / `Shift+L` | Step forward 1 second |
| `Space` / `P` | Toggle play/pause |
| `K` | Pause and snap video to cursor |
| `E` | Export clip |
| `M` | Jump to next export marker (wraps) |
Arrow keys and J/K/L are ignored when a text field has focus.
### Mask generation tools
Two standalone scripts live in `tools/`. They are run by the app via a managed venv but can also be called directly:
Each export creates a group subfolder containing the overlapping sub-clips:
```
python tools/sam_masks.py --input clip.mp4 --output masks_dir/
python tools/depth_masks.py --input clip.mp4 --output masks_dir/
output/
clip_001/
clip_001_0.mp4 # starts at cursor
clip_001_1.mp4 # starts at cursor + spread
clip_001_2.mp4 # starts at cursor + 2 * spread
clip_002/
...
```
Both output one binary PNG per frame (`frame_0000.png`, …) where white = foreground.
With WebP sequence format, each sub-clip becomes a directory of frames plus a `.wav`:
- **SAM2** (`sam_masks.py`) — uses `facebook/sam2-hiera-large`; center-point prompt propagated across all frames
- **Depth Anything V2** (`depth_masks.py`) — uses `depth-anything/Depth-Anything-V2-Large-hf`; Otsu threshold on the depth map
```
output/
clip_001/
clip_001_0/
frame_0001.webp
frame_0002.webp
...
clip_001_0.wav
```
### Sound annotation
Set a **Label** (e.g. "dog barking") and **Category** (Human / Animal / Vehicle / Tool / Music / Nature / Sport / Other) before exporting. These are saved to:
- `dataset.json` in the export folder — one entry per clip with `path` and `label`
- The SQLite database — label and category, for recall when you revisit a marker
Labels persist between exports so you can cut many clips of the same class without retyping.
### Overwrite and delete
- **Double-click** a timeline marker to enter overwrite mode — the next export re-encodes all clips in that group to their original paths
- **Right-click** a marker to delete it from the database
- The **Delete** button removes all clips in a group from disk, database, and `dataset.json`
## Database
Export history is stored in `~/.8cut.db` (SQLite). The database records filename, start time, and output path for every clip. When you open a file, 8-cut checks whether a similar filename has been processed before (stripping resolution tags like `_2160p`, `_1080p`, codec tags, etc.) and pre-populates the timeline with existing markers.
Export history is stored in `~/.8cut.db` (SQLite). The database records filename, start time, output path, label, category, and all encoding settings for every clip. When you open a file, 8-cut matches the filename and pre-populates the timeline with existing markers.
## Testing
@@ -94,4 +134,8 @@ Export history is stored in `~/.8cut.db` (SQLite). The database records filename
pytest tests/ -v
```
38 unit tests covering path builders, ffmpeg command generation, time formatting, and the processed-clips database.
46 unit tests covering path builders, ffmpeg command generation, time formatting, database operations, group queries, profile isolation, and annotation handling.
## License
[GNU General Public License v3.0](https://github.com/ethanfel/8-cut/blob/master/LICENSE)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+95
View File
@@ -0,0 +1,95 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 200">
<defs>
<linearGradient id="timeline-bg" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e1e1e"/>
<stop offset="100%" stop-color="#2a2a2a"/>
</linearGradient>
<linearGradient id="selection" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#3c82dc" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#3c82dc" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="eight-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffd230"/>
<stop offset="100%" stop-color="#e6a800"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="720" height="200" rx="12" fill="#161616"/>
<!-- Timeline track -->
<rect x="40" y="100" width="640" height="48" rx="4" fill="url(#timeline-bg)" stroke="#333" stroke-width="1"/>
<!-- Timeline lane -->
<rect x="40" y="112" width="640" height="24" rx="2" fill="#2a2a2a"/>
<!-- Selection region (8 seconds) -->
<rect x="240" y="100" width="140" height="48" rx="2" fill="url(#selection)"/>
<line x1="240" y1="100" x2="240" y2="148" stroke="#3c82dc" stroke-width="2" stroke-opacity="0.8"/>
<line x1="380" y1="100" x2="380" y2="148" stroke="#3c82dc" stroke-width="2" stroke-opacity="0.8"/>
<!-- Playhead -->
<line x1="240" y1="96" x2="240" y2="152" stroke="#ffd230" stroke-width="2"/>
<polygon points="234,96 246,96 240,104" fill="#ffd230"/>
<!-- Ruler ticks -->
<g stroke="#555" stroke-width="1">
<line x1="80" y1="100" x2="80" y2="108"/>
<line x1="120" y1="100" x2="120" y2="105"/>
<line x1="160" y1="100" x2="160" y2="108"/>
<line x1="200" y1="100" x2="200" y2="105"/>
<line x1="240" y1="100" x2="240" y2="108"/>
<line x1="280" y1="100" x2="280" y2="105"/>
<line x1="320" y1="100" x2="320" y2="108"/>
<line x1="360" y1="100" x2="360" y2="105"/>
<line x1="400" y1="100" x2="400" y2="108"/>
<line x1="440" y1="100" x2="440" y2="105"/>
<line x1="480" y1="100" x2="480" y2="108"/>
<line x1="520" y1="100" x2="520" y2="105"/>
<line x1="560" y1="100" x2="560" y2="108"/>
<line x1="600" y1="100" x2="600" y2="105"/>
<line x1="640" y1="100" x2="640" y2="108"/>
</g>
<!-- Export markers -->
<g>
<line x1="130" y1="100" x2="130" y2="148" stroke="#dc3c3c" stroke-width="2"/>
<rect x="130" y="102" width="14" height="12" rx="1" fill="#c83232"/>
<text x="137" y="112" font-family="sans-serif" font-size="9" fill="white" text-anchor="middle">1</text>
</g>
<g>
<line x1="390" y1="100" x2="390" y2="148" stroke="#dc3c3c" stroke-width="2"/>
<rect x="390" y="102" width="14" height="12" rx="1" fill="#c83232"/>
<text x="397" y="112" font-family="sans-serif" font-size="9" fill="white" text-anchor="middle">2</text>
</g>
<g>
<line x1="540" y1="100" x2="540" y2="148" stroke="#dc3c3c" stroke-width="2"/>
<rect x="540" y="102" width="14" height="12" rx="1" fill="#c83232"/>
<text x="547" y="112" font-family="sans-serif" font-size="9" fill="white" text-anchor="middle">3</text>
</g>
<!-- "8" numeral -->
<text x="100" y="72" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="72" font-weight="bold" fill="url(#eight-grad)">8</text>
<!-- "-cut" text -->
<text x="148" y="70" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="48" font-weight="300" fill="#cccccc">-cut</text>
<!-- Scissors icon near playhead -->
<g transform="translate(296, 82) scale(0.7)" fill="none" stroke="#999" stroke-width="2" stroke-linecap="round">
<circle cx="5" cy="5" r="4" />
<circle cx="5" cy="19" r="4" />
<line x1="9" y1="7" x2="20" y2="17"/>
<line x1="9" y1="17" x2="20" y2="7"/>
</g>
<!-- Tagline -->
<text x="400" y="72" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="14" fill="#777">8-second clips for foley datasets</text>
<!-- Duration label in selection -->
<text x="310" y="130" font-family="'Courier New', monospace" font-size="13" fill="#aad4ff" text-anchor="middle" opacity="0.9">8.0s</text>
<!-- Time labels -->
<text x="40" y="166" font-family="'Courier New', monospace" font-size="10" fill="#666">0:00</text>
<text x="230" y="166" font-family="'Courier New', monospace" font-size="10" fill="#e6a800">1:15</text>
<text x="640" y="166" font-family="'Courier New', monospace" font-size="10" fill="#666">5:00</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+62
View File
@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 260">
<defs>
<marker id="al" markerWidth="6" markerHeight="4" refX="0" refY="2" orient="auto">
<path d="M6,0 L0,2 L6,4" fill="#777"/>
</marker>
<marker id="ar" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto">
<path d="M0,0 L6,2 L0,4" fill="#777"/>
</marker>
<marker id="gl" markerWidth="6" markerHeight="4" refX="0" refY="2" orient="auto">
<path d="M6,0 L0,2 L6,4" fill="#e6a800"/>
</marker>
<marker id="gr" markerWidth="6" markerHeight="4" refX="6" refY="2" orient="auto">
<path d="M0,0 L6,2 L0,4" fill="#e6a800"/>
</marker>
</defs>
<!-- Background -->
<rect width="720" height="260" rx="12" fill="#161616"/>
<!-- Cursor / playhead -->
<text x="110" y="32" font-family="'Courier New', monospace" font-size="11" fill="#e6a800" text-anchor="middle">cursor</text>
<polygon points="104,38 116,38 110,46" fill="#ffd230"/>
<line x1="110" y1="46" x2="110" y2="195" stroke="#ffd230" stroke-width="1.5" stroke-dasharray="4,3"/>
<!-- Dashed guide lines at clip start offsets -->
<line x1="212" y1="56" x2="212" y2="195" stroke="#444" stroke-width="1" stroke-dasharray="2,4"/>
<line x1="314" y1="56" x2="314" y2="195" stroke="#444" stroke-width="1" stroke-dasharray="2,4"/>
<!-- Clip 0 -->
<text x="20" y="74" font-family="'Courier New', monospace" font-size="10" fill="#999">clip_001_0</text>
<rect x="110" y="56" width="272" height="28" rx="4" fill="#3c82dc" opacity="0.9"/>
<text x="246" y="74" font-family="'Courier New', monospace" font-size="11" fill="white" text-anchor="middle" opacity="0.9">8s</text>
<!-- Spread annotation 1 -->
<line x1="110" y1="88" x2="110" y2="104" stroke="#666" stroke-width="1"/>
<line x1="212" y1="88" x2="212" y2="104" stroke="#666" stroke-width="1"/>
<line x1="114" y1="96" x2="208" y2="96" stroke="#777" stroke-width="1" marker-start="url(#al)" marker-end="url(#ar)"/>
<text x="161" y="93" font-family="'Courier New', monospace" font-size="9" fill="#999" text-anchor="middle">spread 3s</text>
<!-- Clip 1 -->
<text x="20" y="126" font-family="'Courier New', monospace" font-size="10" fill="#999">clip_001_1</text>
<rect x="212" y="108" width="272" height="28" rx="4" fill="#5a9be6" opacity="0.85"/>
<text x="348" y="126" font-family="'Courier New', monospace" font-size="11" fill="white" text-anchor="middle" opacity="0.9">8s</text>
<!-- Spread annotation 2 -->
<line x1="212" y1="140" x2="212" y2="156" stroke="#666" stroke-width="1"/>
<line x1="314" y1="140" x2="314" y2="156" stroke="#666" stroke-width="1"/>
<line x1="216" y1="148" x2="310" y2="148" stroke="#777" stroke-width="1" marker-start="url(#al)" marker-end="url(#ar)"/>
<text x="263" y="145" font-family="'Courier New', monospace" font-size="9" fill="#999" text-anchor="middle">spread 3s</text>
<!-- Clip 2 -->
<text x="20" y="178" font-family="'Courier New', monospace" font-size="10" fill="#999">clip_001_2</text>
<rect x="314" y="160" width="272" height="28" rx="4" fill="#78b4f0" opacity="0.8"/>
<text x="450" y="178" font-family="'Courier New', monospace" font-size="11" fill="white" text-anchor="middle" opacity="0.9">8s</text>
<!-- Total span annotation -->
<line x1="110" y1="198" x2="110" y2="218" stroke="#e6a800" stroke-width="1"/>
<line x1="586" y1="198" x2="586" y2="218" stroke="#e6a800" stroke-width="1"/>
<line x1="114" y1="210" x2="582" y2="210" stroke="#e6a800" stroke-width="1" marker-start="url(#gl)" marker-end="url(#gr)"/>
<text x="348" y="235" font-family="'Courier New', monospace" font-size="12" fill="#e6a800" text-anchor="middle">total span: 14s</text>
<text x="348" y="250" font-family="'Courier New', monospace" font-size="10" fill="#666" text-anchor="middle">8 + (n&#x2212;1) &#xd7; spread</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+132
View File
@@ -0,0 +1,132 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#141414"/>
<stop offset="100%" stop-color="#1a1a1a"/>
</linearGradient>
<linearGradient id="tl-bg" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e1e1e"/>
<stop offset="100%" stop-color="#262626"/>
</linearGradient>
<linearGradient id="sel" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#3c82dc" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#3c82dc" stop-opacity="0.2"/>
</linearGradient>
<linearGradient id="eight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffd230"/>
<stop offset="100%" stop-color="#d4a000"/>
</linearGradient>
<linearGradient id="glow" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0%" stop-color="#ffd230" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#ffd230" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="1280" height="640" fill="url(#bg)"/>
<!-- Subtle glow behind title -->
<ellipse cx="640" cy="200" rx="400" ry="140" fill="url(#glow)"/>
<!-- Title: "8" -->
<text x="440" y="250" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="180" font-weight="bold" fill="url(#eight)">8</text>
<!-- Title: "-cut" -->
<text x="560" y="244" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="110" font-weight="300" fill="#cccccc">-cut</text>
<!-- Tagline -->
<text x="640" y="310" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="26" fill="#777" text-anchor="middle">8-second video clips for foley datasets</text>
<!-- ===== Timeline ===== -->
<!-- Timeline track -->
<rect x="120" y="380" width="1040" height="72" rx="6" fill="url(#tl-bg)" stroke="#333" stroke-width="1"/>
<!-- Timeline lane -->
<rect x="120" y="398" width="1040" height="36" rx="3" fill="#2a2a2a"/>
<!-- Ruler ticks -->
<g stroke="#555" stroke-width="1">
<line x1="172" y1="380" x2="172" y2="392"/>
<line x1="224" y1="380" x2="224" y2="387"/>
<line x1="276" y1="380" x2="276" y2="392"/>
<line x1="328" y1="380" x2="328" y2="387"/>
<line x1="380" y1="380" x2="380" y2="392"/>
<line x1="432" y1="380" x2="432" y2="387"/>
<line x1="484" y1="380" x2="484" y2="392"/>
<line x1="536" y1="380" x2="536" y2="387"/>
<line x1="588" y1="380" x2="588" y2="392"/>
<line x1="640" y1="380" x2="640" y2="387"/>
<line x1="692" y1="380" x2="692" y2="392"/>
<line x1="744" y1="380" x2="744" y2="387"/>
<line x1="796" y1="380" x2="796" y2="392"/>
<line x1="848" y1="380" x2="848" y2="387"/>
<line x1="900" y1="380" x2="900" y2="392"/>
<line x1="952" y1="380" x2="952" y2="387"/>
<line x1="1004" y1="380" x2="1004" y2="392"/>
<line x1="1056" y1="380" x2="1056" y2="387"/>
<line x1="1108" y1="380" x2="1108" y2="392"/>
</g>
<!-- Selection region -->
<rect x="460" y="380" width="220" height="72" rx="2" fill="url(#sel)"/>
<line x1="460" y1="380" x2="460" y2="452" stroke="#3c82dc" stroke-width="2" stroke-opacity="0.8"/>
<line x1="680" y1="380" x2="680" y2="452" stroke="#3c82dc" stroke-width="2" stroke-opacity="0.8"/>
<!-- Playhead -->
<line x1="460" y1="372" x2="460" y2="456" stroke="#ffd230" stroke-width="2.5"/>
<polygon points="452,372 468,372 460,384" fill="#ffd230"/>
<!-- Duration label -->
<text x="570" y="422" font-family="'Courier New', monospace" font-size="18" fill="#aad4ff" text-anchor="middle" opacity="0.8">8.0s</text>
<!-- Export markers -->
<g>
<line x1="260" y1="380" x2="260" y2="452" stroke="#dc3c3c" stroke-width="2"/>
<rect x="260" y="383" width="18" height="15" rx="2" fill="#c83232"/>
<text x="269" y="395" font-family="sans-serif" font-size="11" fill="white" text-anchor="middle" font-weight="bold">1</text>
</g>
<g>
<line x1="700" y1="380" x2="700" y2="452" stroke="#dc3c3c" stroke-width="2"/>
<rect x="700" y="383" width="18" height="15" rx="2" fill="#c83232"/>
<text x="709" y="395" font-family="sans-serif" font-size="11" fill="white" text-anchor="middle" font-weight="bold">2</text>
</g>
<g>
<line x1="940" y1="380" x2="940" y2="452" stroke="#dc3c3c" stroke-width="2"/>
<rect x="940" y="383" width="18" height="15" rx="2" fill="#c83232"/>
<text x="949" y="395" font-family="sans-serif" font-size="11" fill="white" text-anchor="middle" font-weight="bold">3</text>
</g>
<!-- Scissors icon -->
<g transform="translate(554, 356) scale(0.9)" fill="none" stroke="#999" stroke-width="2" stroke-linecap="round">
<circle cx="5" cy="5" r="4"/>
<circle cx="5" cy="19" r="4"/>
<line x1="9" y1="7" x2="20" y2="17"/>
<line x1="9" y1="17" x2="20" y2="7"/>
</g>
<!-- Time labels below timeline -->
<text x="120" y="476" font-family="'Courier New', monospace" font-size="13" fill="#555">0:00</text>
<text x="448" y="476" font-family="'Courier New', monospace" font-size="13" fill="#e6a800">2:30</text>
<text x="1110" y="476" font-family="'Courier New', monospace" font-size="13" fill="#555">8:20</text>
<!-- ===== Feature pills ===== -->
<g font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-size="15">
<!-- Pill backgrounds -->
<rect x="260" y="520" width="120" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
<rect x="400" y="520" width="130" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
<rect x="550" y="520" width="120" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
<rect x="690" y="520" width="140" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
<rect x="850" y="520" width="130" height="30" rx="15" fill="#222" stroke="#444" stroke-width="1"/>
<!-- Pill text -->
<text x="320" y="540" fill="#aaa" text-anchor="middle">Batch export</text>
<text x="465" y="540" fill="#aaa" text-anchor="middle">Portrait crop</text>
<text x="610" y="540" fill="#aaa" text-anchor="middle">Annotation</text>
<text x="760" y="540" fill="#aaa" text-anchor="middle">Fuzzy matching</text>
<text x="915" y="540" fill="#aaa" text-anchor="middle">WebP + WAV</text>
</g>
<!-- Bottom border accent -->
<rect x="0" y="636" width="1280" height="4" fill="#ffd230" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

+46
View File
@@ -0,0 +1,46 @@
# 8-cut Design
## Overview
A Linux desktop tool for dropping a video clip, setting a start point on a timeline, and exporting exactly 8 seconds to a numbered output file.
## Stack
- **Python + PyQt6** — native desktop window, drag & drop, custom timeline widget
- **python-mpv** — embedded video playback with audio, frame-accurate seeking, AB loop for 8s preview
- **ffmpeg** — export via subprocess, always re-encodes to guarantee exactly 8s with no freeze frames
## Layout
Single window, top to bottom:
1. Drop zone / loaded file path display
2. mpv video embed (preview)
3. Custom timeline widget with draggable cursor
4. Playback controls + cursor position display
5. Export controls: base name input, folder picker, next filename preview, Export button
## Behavior
- **Drag & drop** a video file onto the window to load it
- **Timeline**: click or drag to reposition cursor; shows current frame when paused
- **Play 8s**: mpv seeks to cursor position, plays 8 seconds, loops back using mpv AB-loop (`ab-loop-a` = cursor, `ab-loop-b` = cursor + 8s)
- **Pause**: shows current frame at cursor position
- **Export**: runs ffmpeg in a QThread (non-blocking UI)
- Command: `ffmpeg -ss {start} -i {input} -t 8 -c:v libx264 -c:a aac {output}`
- Output: `{folder}/{basename}_{NNN:03d}.mp4`
- Counter auto-increments after each successful export
- Counter resets if base name or folder changes
## Architecture
Single file `main.py` (~300-400 lines):
- `MainWindow(QMainWindow)` — owns all state: file path, cursor (seconds), base name, output folder, export counter
- `TimelineWidget(QWidget)` — custom `paintEvent` draws bar + cursor line; `mousePressEvent`/`mouseMoveEvent` for scrubbing
- `MpvWidget(QWidget)` — embeds mpv using window ID (`wid`), exposes `load(path)`, `seek(t)`, `play_loop(a, b)`, `pause()`
- `ExportWorker(QThread)` — runs ffmpeg subprocess, emits `finished(path)` or `error(msg)` signal
## Export guarantees
Always re-encode (never stream copy) to avoid freeze frames caused by keyframe misalignment at the cut point. Output is always exactly 8.000 seconds.
@@ -0,0 +1,733 @@
# 8-cut Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a Linux desktop tool to drop a video, scrub a timeline, and export exactly 8 seconds to an auto-numbered output file.
**Architecture:** Single `main.py` file with PyQt6 for the window/widgets, python-mpv embedded for playback with AB-loop preview, and ffmpeg subprocess in a QThread for non-blocking export. Pure logic (filename counter, ffmpeg command builder) is tested with pytest; GUI is verified manually.
**Tech Stack:** Python 3.10+, PyQt6, python-mpv, ffmpeg (system), pytest
---
### Task 1: Project setup
**Files:**
- Create: `main.py`
- Create: `requirements.txt`
- Create: `tests/__init__.py`
- Create: `tests/test_utils.py`
**Step 1: Install dependencies**
```bash
pip install PyQt6 python-mpv pytest
```
Verify mpv is on the system:
```bash
mpv --version
ffmpeg -version
```
**Step 2: Create requirements.txt**
```
PyQt6>=6.4
python-mpv>=1.0
pytest>=7.0
```
**Step 3: Create tests/__init__.py**
Empty file.
**Step 4: Create main.py skeleton**
```python
import sys
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtCore import Qt
def main():
app = QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.resize(900, 650)
if __name__ == "__main__":
main()
```
**Step 5: Run to verify window opens**
```bash
python main.py
```
Expected: empty 900×650 window titled "8-cut" appears.
**Step 6: Init git and commit**
```bash
cd /media/p5/8-cut
git init
git add main.py requirements.txt tests/
git commit -m "feat: project skeleton"
```
---
### Task 2: Pure utility functions (TDD)
**Files:**
- Modify: `main.py` — add `build_export_path`, `format_time`
- Modify: `tests/test_utils.py`
**Step 1: Write failing tests**
```python
# tests/test_utils.py
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from main import build_export_path, format_time
def test_build_export_path_first():
assert build_export_path("/out", "clip", 1) == "/out/clip_001.mp4"
def test_build_export_path_counter():
assert build_export_path("/out", "clip", 42) == "/out/clip_042.mp4"
def test_build_export_path_deep_counter():
assert build_export_path("/out", "shot", 999) == "/out/shot_999.mp4"
def test_format_time_seconds():
assert format_time(0.0) == "0:00.0"
def test_format_time_minutes():
assert format_time(75.3) == "1:15.3"
def test_format_time_rounding():
assert format_time(61.05) == "1:01.0"
```
**Step 2: Run tests to verify they fail**
```bash
pytest tests/test_utils.py -v
```
Expected: `ImportError` or `AttributeError` — functions not yet defined.
**Step 3: Add functions to main.py**
Add after the imports:
```python
def build_export_path(folder: str, basename: str, counter: int) -> str:
filename = f"{basename}_{counter:03d}.mp4"
return os.path.join(folder, filename)
def format_time(seconds: float) -> str:
m = int(seconds) // 60
s = seconds - m * 60
return f"{m}:{s:04.1f}"
```
Also add `import os` at the top of main.py.
**Step 4: Run tests to verify they pass**
```bash
pytest tests/test_utils.py -v
```
Expected: all 6 tests PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: add utility functions with tests"
```
---
### Task 3: ExportWorker (QThread)
**Files:**
- Modify: `main.py` — add `ExportWorker` class
- Modify: `tests/test_utils.py` — add ffmpeg command test
**Step 1: Write failing test for command builder**
Add to `tests/test_utils.py`:
```python
from main import build_ffmpeg_command
def test_ffmpeg_command():
cmd = build_ffmpeg_command("/in/video.mp4", 12.5, "/out/clip_001.mp4")
assert cmd[0] == "ffmpeg"
assert "-ss" in cmd
assert str(12.5) in cmd
assert "-t" in cmd
assert "8" in cmd
assert cmd[-1] == "/out/clip_001.mp4"
```
**Step 2: Run to verify it fails**
```bash
pytest tests/test_utils.py::test_ffmpeg_command -v
```
Expected: ImportError.
**Step 3: Add build_ffmpeg_command and ExportWorker to main.py**
Add after imports:
```python
import subprocess
from PyQt6.QtCore import QThread, pyqtSignal
def build_ffmpeg_command(input_path: str, start: float, output_path: str) -> list:
return [
"ffmpeg", "-y",
"-ss", str(start),
"-i", input_path,
"-t", "8",
"-c:v", "libx264",
"-c:a", "aac",
output_path,
]
class ExportWorker(QThread):
finished = pyqtSignal(str) # output path
error = pyqtSignal(str) # error message
def __init__(self, input_path: str, start: float, output_path: str):
super().__init__()
self._input = input_path
self._start = start
self._output = output_path
def run(self):
cmd = build_ffmpeg_command(self._input, self._start, self._output)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0:
self.finished.emit(self._output)
else:
self.error.emit(result.stderr[-500:])
except Exception as e:
self.error.emit(str(e))
```
**Step 4: Run tests**
```bash
pytest tests/test_utils.py -v
```
Expected: all 7 tests PASS.
**Step 5: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: ExportWorker with ffmpeg command builder"
```
---
### Task 4: TimelineWidget
**Files:**
- Modify: `main.py` — add `TimelineWidget` class
**Step 1: Add TimelineWidget**
Add before `MainWindow`:
```python
from PyQt6.QtWidgets import QWidget
from PyQt6.QtGui import QPainter, QColor, QPen
from PyQt6.QtCore import pyqtSignal
class TimelineWidget(QWidget):
cursor_changed = pyqtSignal(float) # emits position in seconds
def __init__(self):
super().__init__()
self.setMinimumHeight(40)
self.setMouseTracking(True)
self._duration = 0.0
self._cursor = 0.0
def set_duration(self, duration: float):
self._duration = duration
self._cursor = 0.0
self.update()
def set_cursor(self, seconds: float):
self._cursor = max(0.0, min(seconds, max(0.0, self._duration - 8.0)))
self.update()
def _pos_to_time(self, x: int) -> float:
if self._duration <= 0 or self.width() <= 0:
return 0.0
ratio = max(0.0, min(1.0, x / self.width()))
return ratio * self._duration
def paintEvent(self, event):
p = QPainter(self)
w, h = self.width(), self.height()
# Background
p.fillRect(0, 0, w, h, QColor(30, 30, 30))
if self._duration <= 0:
return
# 8s selection highlight
x_start = int(self._cursor / self._duration * w)
x_end = int(min(self._cursor + 8.0, self._duration) / self._duration * w)
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
# Cursor line
pen = QPen(QColor(255, 200, 0))
pen.setWidth(2)
p.setPen(pen)
p.drawLine(x_start, 0, x_start, h)
def mousePressEvent(self, event):
self._seek(event.position().x())
def mouseMoveEvent(self, event):
if event.buttons():
self._seek(event.position().x())
def _seek(self, x: float):
t = self._pos_to_time(int(x))
self.set_cursor(t)
self.cursor_changed.emit(self._cursor)
```
**Step 2: Verify it renders — quick smoke test**
Temporarily add to `MainWindow.__init__`:
```python
from PyQt6.QtWidgets import QVBoxLayout, QWidget as QW
container = QW()
layout = QVBoxLayout(container)
self._timeline = TimelineWidget()
self._timeline.set_duration(60.0)
layout.addWidget(self._timeline)
self.setCentralWidget(container)
```
Run `python main.py` — you should see a dark bar. Click/drag on it to move the yellow cursor with blue highlight.
**Step 3: Remove the temporary test code from MainWindow**
Revert `MainWindow.__init__` to just `super().__init__()`, `setWindowTitle`, `resize`.
**Step 4: Commit**
```bash
git add main.py
git commit -m "feat: TimelineWidget with cursor and 8s highlight"
```
---
### Task 5: MpvWidget
**Files:**
- Modify: `main.py` — add `MpvWidget` class
**Step 1: Add MpvWidget**
Add before `MainWindow`:
```python
import mpv
from PyQt6.QtWidgets import QFrame
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
class MpvWidget(QFrame):
file_loaded = pyqtSignal() # emitted (on Qt thread) when a file is ready
def __init__(self):
super().__init__()
self.setMinimumSize(640, 360)
self.setStyleSheet("background: black;")
# Required so Qt creates a real native window handle for mpv to embed into.
# Without these, mpv opens a separate window instead of embedding.
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow, True)
self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen, True)
self._player = None
def _init_player(self):
if self._player is not None:
return
self._player = mpv.MPV(
wid=str(int(self.winId())),
keep_open=True,
pause=True,
)
# mpv fires events on its own thread; bounce to Qt thread via QTimer.
@self._player.event_callback("file-loaded")
def _on_file_loaded(event):
QTimer.singleShot(0, self.file_loaded.emit)
def load(self, path: str):
self._init_player()
self._player.play(path)
def seek(self, t: float):
if self._player:
self._player.pause = True
self._player.seek(t, "absolute")
def play_loop(self, a: float, b: float):
if self._player:
self._player["ab-loop-a"] = a
self._player["ab-loop-b"] = b
self._player.pause = False
def stop_loop(self):
if self._player:
# ab-loop-a/b are numeric properties — setting to "no" via dict
# accessor throws TypeError. Disable loop via ab_loop_count instead.
self._player.ab_loop_count = 0
self._player.pause = True
def get_duration(self) -> float:
if self._player:
d = self._player.duration
return d if d else 0.0
return 0.0
def closeEvent(self, event):
if self._player:
self._player.terminate()
super().closeEvent(event)
```
**Step 2: Smoke test**
Temporarily in `MainWindow.__init__`:
```python
from PyQt6.QtWidgets import QVBoxLayout, QWidget as QW
container = QW()
layout = QVBoxLayout(container)
self._mpv = MpvWidget()
layout.addWidget(self._mpv)
self.setCentralWidget(container)
# after show(), call: self._mpv.load("/path/to/any/video.mp4")
```
Run, load a real video path. Should display and be paused.
**Step 3: Remove temporary test code**
**Step 4: Commit**
```bash
git add main.py
git commit -m "feat: MpvWidget with seek and AB-loop"
```
---
### Task 6: MainWindow — full UI
**Files:**
- Modify: `main.py` — implement complete `MainWindow`
**Step 1: Add all imports at top of main.py**
```python
import sys
import os
import subprocess
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt6.QtGui import QPainter, QColor, QPen, QDragEnterEvent, QDropEvent
import mpv
```
**Step 2: Replace MainWindow with full implementation**
```python
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.resize(900, 680)
self.setAcceptDrops(True)
# State
self._file_path: str = ""
self._cursor: float = 0.0
self._export_counter: int = 1
self._export_worker: ExportWorker | None = None
# Widgets
self._mpv = MpvWidget()
self._mpv.file_loaded.connect(self._after_load)
self._timeline = TimelineWidget()
self._timeline.cursor_changed.connect(self._on_cursor_changed)
self._lbl_file = QLabel("Drop a video file here")
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
self._btn_play = QPushButton("▶ Play 8s")
self._btn_play.setEnabled(False)
self._btn_play.clicked.connect(self._on_play)
self._btn_pause = QPushButton("⏸ Pause")
self._btn_pause.setEnabled(False)
self._btn_pause.clicked.connect(self._on_pause)
self._lbl_cursor = QLabel("cursor: --")
self._lbl_duration = QLabel("dur: --")
self._txt_name = QLineEdit("clip")
self._txt_name.setPlaceholderText("base name")
self._txt_name.setMaximumWidth(150)
self._txt_name.textChanged.connect(self._reset_counter)
self._txt_folder = QLineEdit(str(Path.home()))
self._btn_folder = QPushButton("Browse")
self._btn_folder.clicked.connect(self._pick_folder)
self._lbl_next = QLabel()
self._update_next_label()
self._btn_export = QPushButton("Export")
self._btn_export.setEnabled(False)
self._btn_export.clicked.connect(self._on_export)
# Layout
top_bar = QHBoxLayout()
top_bar.addWidget(self._lbl_file, stretch=1)
controls = QHBoxLayout()
controls.addWidget(self._btn_play)
controls.addWidget(self._btn_pause)
controls.addStretch()
controls.addWidget(self._lbl_cursor)
controls.addWidget(self._lbl_duration)
export_row = QHBoxLayout()
export_row.addWidget(QLabel("Name:"))
export_row.addWidget(self._txt_name)
export_row.addWidget(QLabel("Folder:"))
export_row.addWidget(self._txt_folder, stretch=1)
export_row.addWidget(self._btn_folder)
export_row.addWidget(self._lbl_next)
export_row.addWidget(self._btn_export)
root = QVBoxLayout()
root.addLayout(top_bar)
root.addWidget(self._mpv, stretch=1)
root.addWidget(self._timeline)
root.addLayout(controls)
root.addLayout(export_row)
container = QWidget()
container.setLayout(root)
self.setCentralWidget(container)
self.setStatusBar(QStatusBar())
# --- Drag & Drop ---
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent):
urls = event.mimeData().urls()
if urls:
path = urls[0].toLocalFile()
self._load_file(path)
def _load_file(self, path: str):
self._file_path = path
self._lbl_file.setText(os.path.basename(path))
self._mpv.load(path)
# _after_load is triggered by MpvWidget.file_loaded signal (connected in __init__)
def _after_load(self):
dur = self._mpv.get_duration()
self._timeline.set_duration(dur)
self._cursor = 0.0
self._lbl_duration.setText(f"dur: {format_time(dur)}")
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True)
# --- Playback ---
def _on_cursor_changed(self, t: float):
self._cursor = t
self._lbl_cursor.setText(f"cursor: {format_time(t)}")
self._mpv.seek(t)
def _on_play(self):
if not self._file_path:
return
self._mpv.play_loop(self._cursor, self._cursor + 8.0)
def _on_pause(self):
self._mpv.stop_loop()
self._mpv.seek(self._cursor)
# --- Export ---
def _pick_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Select output folder")
if folder:
self._txt_folder.setText(folder)
self._reset_counter()
def _reset_counter(self):
self._export_counter = 1
self._update_next_label()
def _update_next_label(self):
path = build_export_path(
self._txt_folder.text(),
self._txt_name.text() or "clip",
self._export_counter,
)
self._lbl_next.setText(f"{os.path.basename(path)}")
def _on_export(self):
if not self._file_path:
return
if self._export_worker and self._export_worker.isRunning():
self.statusBar().showMessage("Export already running…")
return
output = build_export_path(
self._txt_folder.text(),
self._txt_name.text() or "clip",
self._export_counter,
)
self._btn_export.setEnabled(False)
self.statusBar().showMessage(f"Exporting {os.path.basename(output)}")
self._export_worker = ExportWorker(self._file_path, self._cursor, output)
self._export_worker.finished.connect(self._on_export_done)
self._export_worker.error.connect(self._on_export_error)
self._export_worker.start()
def _on_export_done(self, path: str):
self._export_counter += 1
self._update_next_label()
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
def _on_export_error(self, msg: str):
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Export error: {msg}")
```
**Step 3: Run the app**
```bash
python main.py
```
Expected:
- Window opens with dark drop zone
- Drop a video → preview appears, timeline shows duration
- Drag cursor → video seeks to that frame
- Click "▶ Play 8s" → 8-second loop plays with audio
- Click "⏸ Pause" → pauses and seeks back to cursor
- Click "Export" → exports clip_001.mp4 to home folder, counter becomes 2
**Step 4: Run all tests to confirm nothing broken**
```bash
pytest tests/ -v
```
Expected: all tests PASS.
**Step 5: Commit**
```bash
git add main.py
git commit -m "feat: complete MainWindow UI with playback and export"
```
---
### Task 7: Final polish
**Files:**
- Modify: `main.py` — dark theme, minor UX
**Step 1: Add dark stylesheet to main()**
```python
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
app.setStyleSheet("""
QWidget { background: #1e1e1e; color: #ddd; }
QPushButton { background: #333; border: 1px solid #555; padding: 4px 10px; border-radius: 3px; }
QPushButton:hover { background: #444; }
QPushButton:disabled { color: #555; }
QLineEdit { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
QStatusBar { color: #aaa; }
""")
win = MainWindow()
win.show()
sys.exit(app.exec())
```
**Step 2: Run and verify visuals**
```bash
python main.py
```
Drop a video, scrub, export. Everything should look clean and dark.
**Step 3: Final commit**
```bash
git add main.py
git commit -m "feat: dark theme, complete 8-cut tool"
```
@@ -0,0 +1,494 @@
# Playlist & Processed-Files Database Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a file queue (playlist) that auto-advances after each export, and a SQLite database that warns when a newly loaded file is fuzzy-similar to one already processed.
**Architecture:** `ProcessedDB` wraps `sqlite3` with fuzzy matching via `difflib.SequenceMatcher` on normalized filenames (resolution/quality tags stripped). `PlaylistWidget` is a `QListWidget` subclass that owns the queue and emits `file_selected` on advance or click. `MainWindow` is wired to use both.
**Tech Stack:** Python built-ins only — `sqlite3`, `difflib`, `re`, `datetime`. No new dependencies.
---
### Task 1: `_normalize_filename` and `ProcessedDB` (TDD)
**Files:**
- Modify: `main.py` — add `_normalize_filename`, `ProcessedDB`
- Modify: `tests/test_utils.py` — add DB and normalization tests
**Step 1: Add imports at top of main.py**
Add to the existing imports:
```python
import re
import sqlite3
from datetime import datetime
from difflib import SequenceMatcher
```
**Step 2: Write failing tests**
Add to `tests/test_utils.py`:
```python
import tempfile, os
from main import _normalize_filename, ProcessedDB
# --- _normalize_filename ---
def test_normalize_strips_extension():
assert _normalize_filename("clip.mp4") == "clip"
def test_normalize_strips_resolution():
assert _normalize_filename("clip_2160p.mp4") == "clip"
def test_normalize_strips_1080p():
assert _normalize_filename("clip_1080p.mkv") == "clip"
def test_normalize_strips_multiple_tags():
assert _normalize_filename("show_1080p_HDR.mkv") == "show"
def test_normalize_lowercases():
assert _normalize_filename("MyVideo_4K.mp4") == "myvideo"
def test_normalize_collapses_separators():
assert _normalize_filename("my__video--2160p.mp4") == "my_video"
# --- ProcessedDB ---
def test_db_add_and_find_exact():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4")
assert db.find_similar("video.mp4") == "video.mp4"
finally:
os.unlink(path)
def test_db_find_similar_resolution_variant():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("episode_s01e01_2160p.mkv")
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
finally:
os.unlink(path)
def test_db_find_similar_no_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("alpha.mp4")
assert db.find_similar("completely_different_zzzz.mp4") is None
finally:
os.unlink(path)
def test_db_disabled_survives_bad_path():
db = ProcessedDB("/no/such/directory/8cut.db")
db.add("x.mp4") # must not raise
assert db.find_similar("x.mp4") is None # gracefully returns None
```
**Step 3: Run tests to verify they fail**
```bash
pytest tests/test_utils.py -k "normalize or db" -v
```
Expected: ImportError — functions not defined yet.
**Step 4: Add `_normalize_filename` to main.py**
Add after the existing imports, before `build_export_path`:
```python
def _normalize_filename(filename: str) -> str:
"""Strip extension and common resolution/quality tags for fuzzy comparison."""
name = os.path.splitext(filename)[0].lower()
name = re.sub(
r'\b(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?'
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc'
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)\b',
'', name, flags=re.IGNORECASE,
)
name = re.sub(r'[\s_\-\.]+', '_', name).strip('_')
return name
```
**Step 5: Add `ProcessedDB` to main.py**
Add after `_normalize_filename`:
```python
class ProcessedDB:
def __init__(self, db_path: str | None = None):
if db_path is None:
db_path = str(Path.home() / ".8cut.db")
try:
self._con = sqlite3.connect(db_path)
self._con.execute(
"CREATE TABLE IF NOT EXISTS processed "
"(filename TEXT NOT NULL, processed_at TEXT NOT NULL)"
)
self._con.commit()
self._enabled = True
except Exception as e:
print(f"8-cut: DB unavailable: {e}", file=sys.stderr)
self._con = None
self._enabled = False
def add(self, filename: str) -> None:
if not self._enabled:
return
self._con.execute(
"INSERT INTO processed (filename, processed_at) VALUES (?, ?)",
(filename, datetime.utcnow().isoformat()),
)
self._con.commit()
def find_similar(self, filename: str) -> str | None:
if not self._enabled:
return None
rows = self._con.execute(
"SELECT DISTINCT filename FROM processed"
).fetchall()
norm_new = _normalize_filename(filename)
best_ratio, best_match = 0.0, None
for (stored,) in rows:
ratio = SequenceMatcher(
None, norm_new, _normalize_filename(stored)
).ratio()
if ratio >= 0.75 and ratio > best_ratio:
best_ratio, best_match = ratio, stored
return best_match
```
**Step 6: Run tests to verify they pass**
```bash
pytest tests/test_utils.py -k "normalize or db" -v
```
Expected: all 10 new tests PASS (plus existing 8 = 18 total).
**Step 7: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: ProcessedDB and _normalize_filename with tests"
```
---
### Task 2: `PlaylistWidget`
**Files:**
- Modify: `main.py` — add `PlaylistWidget` class
**Step 1: Add PlaylistWidget before MainWindow**
Add after `MpvWidget`, before `main()`:
```python
class PlaylistWidget(QListWidget):
file_selected = pyqtSignal(str) # emits full path of selected file
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
self.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly)
self.setFixedWidth(200)
self.setWordWrap(True)
self._paths: list[str] = []
self.itemClicked.connect(self._on_item_clicked)
def add_files(self, paths: list[str]) -> None:
"""Append paths not already in queue; auto-select first if queue was empty."""
was_empty = len(self._paths) == 0
for path in paths:
if path not in self._paths and os.path.isfile(path):
self._paths.append(path)
self.addItem(os.path.basename(path))
if was_empty and self._paths:
self._select(0)
def advance(self) -> None:
"""Move to next item in queue. Does nothing if at end."""
row = self.currentRow()
if row < self.count() - 1:
self._select(row + 1)
def current_path(self) -> str | None:
row = self.currentRow()
return self._paths[row] if 0 <= row < len(self._paths) else None
def _select(self, row: int) -> None:
self.setCurrentRow(row)
self._refresh_labels()
self.file_selected.emit(self._paths[row])
def _refresh_labels(self) -> None:
current = self.currentRow()
for i in range(self.count()):
name = os.path.basename(self._paths[i])
self.item(i).setText(f"{name}" if i == current else name)
def _on_item_clicked(self, item: QListWidgetItem) -> None:
self._select(self.row(item))
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent) -> None:
paths = [
u.toLocalFile() for u in event.mimeData().urls()
if os.path.isfile(u.toLocalFile())
]
if paths:
self.add_files(paths)
```
**Step 2: Add missing imports**
`QListWidget`, `QListWidgetItem`, and `QAbstractItemView` need to be in the QtWidgets import line. Check which are missing and add them. The updated import line should be:
```python
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter,
)
```
(`QSplitter` is added now for use in Task 3.)
**Step 3: Verify headless import**
```bash
python -c "from main import PlaylistWidget"
```
Expected: no output.
**Step 4: Run all tests**
```bash
pytest tests/ -v
```
Expected: all 18 tests pass.
**Step 5: Commit**
```bash
git add main.py
git commit -m "feat: PlaylistWidget with drop support and auto-advance"
```
---
### Task 3: Wire MainWindow
**Files:**
- Modify: `main.py` — update `MainWindow.__init__`, `_load_file`, `_after_load`, `_on_export_done`, layout
**Step 1: Update MainWindow.__init__**
Replace the `MainWindow.__init__` method entirely with the version below. Key changes:
- Add `self._db = ProcessedDB()` and `self._playlist = PlaylistWidget()`
- Connect `_playlist.file_selected` to `_load_file`
- Change the root layout to a horizontal split: playlist on left, existing content on right
- Remove `self.setAcceptDrops(True)` from MainWindow (playlist handles drops now)
```python
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.resize(1100, 680)
# Services
self._db = ProcessedDB()
# State
self._file_path: str = ""
self._cursor: float = 0.0
self._export_counter: int = 1
self._export_worker: ExportWorker | None = None
# Widgets
self._playlist = PlaylistWidget()
self._playlist.file_selected.connect(self._load_file)
self._mpv = MpvWidget()
self._mpv.file_loaded.connect(self._after_load)
self._timeline = TimelineWidget()
self._timeline.cursor_changed.connect(self._on_cursor_changed)
self._lbl_file = QLabel("Drop files onto the queue →")
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
self._btn_play = QPushButton("▶ Play 8s")
self._btn_play.setEnabled(False)
self._btn_play.clicked.connect(self._on_play)
self._btn_pause = QPushButton("⏸ Pause")
self._btn_pause.setEnabled(False)
self._btn_pause.clicked.connect(self._on_pause)
self._lbl_cursor = QLabel("cursor: --")
self._lbl_duration = QLabel("dur: --")
self._txt_name = QLineEdit("clip")
self._txt_name.setPlaceholderText("base name")
self._txt_name.setMaximumWidth(150)
self._txt_name.textChanged.connect(self._reset_counter)
self._txt_folder = QLineEdit(str(Path.home()))
self._txt_folder.textChanged.connect(self._reset_counter)
self._btn_folder = QPushButton("Browse")
self._btn_folder.clicked.connect(self._pick_folder)
self._lbl_next = QLabel()
self._update_next_label()
self._btn_export = QPushButton("Export")
self._btn_export.setEnabled(False)
self._btn_export.clicked.connect(self._on_export)
# Right-side layout (video + controls)
top_bar = QHBoxLayout()
top_bar.addWidget(self._lbl_file, stretch=1)
controls = QHBoxLayout()
controls.addWidget(self._btn_play)
controls.addWidget(self._btn_pause)
controls.addStretch()
controls.addWidget(self._lbl_cursor)
controls.addWidget(self._lbl_duration)
export_row = QHBoxLayout()
export_row.addWidget(QLabel("Name:"))
export_row.addWidget(self._txt_name)
export_row.addWidget(QLabel("Folder:"))
export_row.addWidget(self._txt_folder, stretch=1)
export_row.addWidget(self._btn_folder)
export_row.addWidget(self._lbl_next)
export_row.addWidget(self._btn_export)
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.addLayout(top_bar)
right_layout.addWidget(self._mpv, stretch=1)
right_layout.addWidget(self._timeline)
right_layout.addLayout(controls)
right_layout.addLayout(export_row)
# Left: playlist label + list
queue_label = QLabel("Queue")
queue_label.setStyleSheet("color: #aaa; padding: 4px;")
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(4, 4, 4, 4)
left_layout.addWidget(queue_label)
left_layout.addWidget(self._playlist)
# Root: horizontal split
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(left)
splitter.addWidget(right)
splitter.setSizes([200, 900])
splitter.setCollapsible(0, False)
splitter.setCollapsible(1, False)
self.setCentralWidget(splitter)
self.setStatusBar(QStatusBar())
```
**Step 2: Update `_load_file` — remove isfile guard (PlaylistWidget already filters)**
```python
def _load_file(self, path: str):
self._file_path = path
self._lbl_file.setText(os.path.basename(path))
self._mpv.load(path)
# _after_load triggered by MpvWidget.file_loaded signal
```
**Step 3: Update `_after_load` — add DB similarity check**
```python
def _after_load(self):
dur = self._mpv.get_duration()
self._timeline.set_duration(dur)
self._cursor = 0.0
self._lbl_duration.setText(f"dur: {format_time(dur)}")
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True)
match = self._db.find_similar(os.path.basename(self._file_path))
if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
```
**Step 4: Update `_on_export_done` — record to DB and advance queue**
```python
def _on_export_done(self, path: str):
self._db.add(os.path.basename(self._file_path))
self._export_counter += 1
self._update_next_label()
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
self._playlist.advance()
```
**Step 5: Remove `dragEnterEvent` and `dropEvent` from MainWindow**
These methods are no longer needed on `MainWindow` — drops go directly to `PlaylistWidget`. Delete both methods.
**Step 6: Verify headless import**
```bash
python -c "from main import MainWindow"
```
Expected: no output.
**Step 7: Run all tests**
```bash
pytest tests/ -v
```
Expected: all 18 tests pass.
**Step 8: Manual smoke test**
```bash
python main.py
```
- Drop one or more video files onto the queue panel → they appear in the list
- First file loads automatically into the player
- Scrub, play, pause — all work as before
- Export → file saved, counter increments, next file in queue loads automatically
- Drop the same file again → `⚠ Similar to already processed:` appears in status bar
**Step 9: Commit**
```bash
git add main.py
git commit -m "feat: wire playlist and DB into MainWindow"
```
@@ -0,0 +1,430 @@
# Timeline Markers Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Show numbered markers on the timeline at positions where clips were previously extracted from the current source file, with a hover tooltip showing the output path.
**Architecture:** DB schema is migrated to store `start_time` and `output_path` per export row (dropping the old UNIQUE constraint). `TimelineWidget` gains `set_markers()`, draws red numbered lines in `paintEvent`, and shows `QToolTip` on hover. `MainWindow` feeds markers to the timeline on load and after each export.
**Tech Stack:** Python built-ins (`sqlite3`, `re`, `difflib`), PyQt6 (`QToolTip`, `QCursor`, `QFont`). No new dependencies.
---
### Task 1: DB schema migration and new methods (TDD)
**Files:**
- Modify: `main.py` — update `ProcessedDB.__init__`, `add`, add `get_markers`
- Modify: `tests/test_utils.py` — update existing DB tests, add marker tests
**Step 1: Write failing tests**
Replace the four existing `test_db_*` tests and add new ones in `tests/test_utils.py`:
```python
# --- ProcessedDB (updated) ---
def test_db_add_and_find_exact():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 12.5, "/out/clip_001.mp4")
assert db.find_similar("video.mp4") == "video.mp4"
finally:
os.unlink(path)
def test_db_find_similar_resolution_variant():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("episode_s01e01_2160p.mkv", 0.0, "/out/ep_001.mp4")
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
finally:
os.unlink(path)
def test_db_find_similar_no_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("alpha.mp4", 0.0, "/out/alpha_001.mp4")
assert db.find_similar("completely_different_zzzz.mp4") is None
finally:
os.unlink(path)
def test_db_disabled_survives_bad_path():
db = ProcessedDB("/no/such/directory/8cut.db")
db.add("x.mp4", 0.0, "/out/x_001.mp4") # must not raise
assert db.find_similar("x.mp4") is None
def test_db_get_markers_returns_sorted():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 30.0, "/out/clip_002.mp4")
db.add("video.mp4", 10.0, "/out/clip_001.mp4")
db.add("video.mp4", 50.0, "/out/clip_003.mp4")
markers = db.get_markers("video.mp4")
assert len(markers) == 3
assert markers[0] == (10.0, 1, "/out/clip_001.mp4")
assert markers[1] == (30.0, 2, "/out/clip_002.mp4")
assert markers[2] == (50.0, 3, "/out/clip_003.mp4")
finally:
os.unlink(path)
def test_db_get_markers_fuzzy_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("show_2160p.mkv", 5.0, "/out/s_001.mp4")
markers = db.get_markers("show_1080p.mkv")
assert len(markers) == 1
assert markers[0][0] == 5.0
assert markers[0][2] == "/out/s_001.mp4"
finally:
os.unlink(path)
def test_db_get_markers_no_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
markers = db.get_markers("nothing.mp4")
assert markers == []
finally:
os.unlink(path)
def test_db_get_markers_disabled():
db = ProcessedDB("/no/such/directory/8cut.db")
assert db.get_markers("x.mp4") == []
```
**Step 2: Run to verify they fail**
```bash
pytest tests/test_utils.py -k "db" -v
```
Expected: failures — `add` has wrong signature, `get_markers` doesn't exist.
**Step 3: Update `ProcessedDB` in main.py**
Replace the entire `ProcessedDB` class:
```python
class ProcessedDB:
_SCHEMA_VERSION = 2 # bump when schema changes
def __init__(self, db_path: str | None = None):
if db_path is None:
db_path = str(Path.home() / ".8cut.db")
try:
self._con = sqlite3.connect(db_path)
self._migrate()
self._enabled = True
except Exception as e:
print(f"8-cut: DB unavailable: {e}", file=sys.stderr)
self._con = None
self._enabled = False
def _migrate(self) -> None:
"""Create or recreate table if schema is outdated."""
cols = {
row[1]
for row in self._con.execute("PRAGMA table_info(processed)").fetchall()
}
needs_recreate = "start_time" not in cols or "output_path" not in cols
if needs_recreate:
self._con.execute("DROP TABLE IF EXISTS processed")
self._con.execute(
"CREATE TABLE IF NOT EXISTS processed ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" filename TEXT NOT NULL,"
" start_time REAL NOT NULL,"
" output_path TEXT NOT NULL,"
" processed_at TEXT NOT NULL"
")"
)
self._con.execute(
"CREATE INDEX IF NOT EXISTS idx_filename ON processed(filename)"
)
self._con.commit()
def add(self, filename: str, start_time: float, output_path: str) -> None:
if not self._enabled:
return
self._con.execute(
"INSERT INTO processed (filename, start_time, output_path, processed_at)"
" VALUES (?, ?, ?, ?)",
(filename, start_time, output_path, datetime.now(timezone.utc).isoformat()),
)
self._con.commit()
def find_similar(self, filename: str) -> str | None:
if not self._enabled:
return None
rows = self._con.execute(
"SELECT DISTINCT filename FROM processed"
).fetchall()
norm_new = _normalize_filename(filename)
best_ratio, best_match = 0.0, None
for (stored,) in rows:
ratio = SequenceMatcher(
None, norm_new, _normalize_filename(stored)
).ratio()
if ratio >= 0.75 and ratio > best_ratio:
best_ratio, best_match = ratio, stored
return best_match
def get_markers(self, filename: str) -> list[tuple[float, int, str]]:
"""Return [(start_time, marker_number, output_path), ...] for the best
fuzzy match of filename, sorted by start_time. Empty list if no match."""
if not self._enabled:
return []
match = self.find_similar(filename)
if match is None:
return []
rows = self._con.execute(
"SELECT start_time, output_path FROM processed"
" WHERE filename = ? ORDER BY start_time",
(match,),
).fetchall()
return [(t, i + 1, p) for i, (t, p) in enumerate(rows)]
```
**Step 4: Run DB tests**
```bash
pytest tests/test_utils.py -k "db" -v
```
Expected: all 8 DB tests PASS.
**Step 5: Run full suite**
```bash
pytest tests/ -v
```
Expected: all tests pass (normalize tests + db tests = 14 DB/normalize tests + 8 original = 22 total — count may vary).
**Step 6: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: DB schema v2 — store start_time and output_path, add get_markers"
```
---
### Task 2: TimelineWidget markers (paint + hover)
**Files:**
- Modify: `main.py` — update `TimelineWidget`
**Step 1: Add missing imports to main.py**
`QToolTip` and `QCursor` are needed. Add them to the existing imports:
```python
from PyQt6.QtWidgets import (
...existing..., QToolTip,
)
from PyQt6.QtGui import (
...existing..., QCursor, QFont,
)
```
**Step 2: Add `set_markers` and update `paintEvent` and `mouseMoveEvent`**
In `TimelineWidget`, add the `_markers` attribute in `__init__`:
```python
self._markers: list[tuple[float, int, str]] = []
```
Add the `set_markers` method:
```python
def set_markers(self, markers: list[tuple[float, int, str]]) -> None:
"""markers: list of (start_time, number, output_path)"""
self._markers = markers
self.update()
```
Replace `paintEvent` with:
```python
def paintEvent(self, event):
p = QPainter(self)
try:
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(30, 30, 30))
if self._duration <= 0:
return
# 8s selection highlight
x_start = int(self._cursor / self._duration * w)
x_end = int(min(self._cursor + 8.0, self._duration) / self._duration * w)
p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
# Cursor line
pen = QPen(QColor(255, 200, 0))
pen.setWidth(2)
p.setPen(pen)
p.drawLine(x_start, 0, x_start, h)
# Markers
font = QFont()
font.setPixelSize(9)
p.setFont(font)
marker_pen = QPen(QColor(220, 60, 60))
marker_pen.setWidth(2)
for (t, num, _path) in self._markers:
if self._duration <= 0:
break
mx = int(t / self._duration * w)
p.setPen(marker_pen)
p.drawLine(mx, 0, mx, h)
p.setPen(QColor(255, 255, 255))
p.drawText(mx + 2, 10, str(num))
finally:
p.end()
```
Replace `mouseMoveEvent` with:
```python
def mouseMoveEvent(self, event):
x = event.position().x()
# Check marker hover (±4px)
if self._duration > 0 and self._markers:
w = self.width()
for (t, _num, output_path) in self._markers:
mx = t / self._duration * w
if abs(x - mx) <= 4:
QToolTip.showText(QCursor.pos(), output_path, self)
if event.buttons():
self._seek(x)
return
QToolTip.hideText()
if event.buttons():
self._seek(x)
```
**Step 3: Verify headless import**
```bash
python -c "from main import TimelineWidget"
```
Expected: no output.
**Step 4: Run all tests**
```bash
pytest tests/ -v
```
Expected: all tests pass.
**Step 5: Commit**
```bash
git add main.py
git commit -m "feat: timeline markers with hover tooltip"
```
---
### Task 3: Wire MainWindow
**Files:**
- Modify: `main.py` — update `_after_load`, `_on_export_done`, add `_refresh_markers`
**Step 1: Add `_refresh_markers` helper to MainWindow**
Add this method after `_after_load`:
```python
def _refresh_markers(self) -> None:
markers = self._db.get_markers(os.path.basename(self._file_path))
self._timeline.set_markers(markers)
```
**Step 2: Update `_after_load`**
Add `self._refresh_markers()` at the end of `_after_load`:
```python
def _after_load(self):
dur = self._mpv.get_duration()
self._timeline.set_duration(dur)
self._cursor = 0.0
self._lbl_duration.setText(f"dur: {format_time(dur)}")
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True)
match = self._db.find_similar(os.path.basename(self._file_path))
if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
else:
self.statusBar().clearMessage()
self._refresh_markers()
```
**Step 3: Update `_on_export_done`**
Pass `start_time` and `output_path` to `db.add`, then refresh markers:
```python
def _on_export_done(self, path: str):
self._db.add(os.path.basename(self._file_path), self._cursor, path)
self._export_counter += 1
self._update_next_label()
self._btn_export.setEnabled(True)
self.statusBar().showMessage(f"Exported: {os.path.basename(path)}")
self._refresh_markers()
self._playlist.advance()
```
**Step 4: Verify headless import**
```bash
python -c "from main import MainWindow"
```
Expected: no output.
**Step 5: Run all tests**
```bash
pytest tests/ -v
```
Expected: all tests pass.
**Step 6: Manual smoke test**
```bash
python main.py
```
- Drop a video, set cursor, export → a red numbered marker `1` appears on the timeline at that position
- Export again at a different position → marker `2` appears
- Hover over a marker → tooltip shows the output file path
- Drop a resolution variant of the same video → markers from the original appear immediately
**Step 7: Commit**
```bash
git add main.py
git commit -m "feat: wire timeline markers into MainWindow"
```
@@ -0,0 +1,51 @@
# Keyframe crop modes design
## Problem
Currently, crop keyframes only store position (time, center). The random portrait and random square checkboxes apply globally to the entire batch. When a batch spans a scene change (e.g. wide landscape to close-up), portrait crop may only make sense for part of the span.
## Solution
Extend keyframes to snapshot the full crop state — position, ratio, and random crop flags — so each sub-clip in a batch inherits crop settings from the latest keyframe at or before its start time.
## Keyframe data
Expand from `(time, center)` to `(time, center, ratio, rand_portrait, rand_square)`:
- `time` (float) — absolute time in seconds
- `center` (float) — horizontal crop position, 0.0 to 1.0
- `ratio` (str | None) — portrait combo value: `None`, `"9:16"`, `"4:5"`, or `"1:1"`
- `rand_portrait` (bool) — random portrait checkbox state
- `rand_square` (bool) — random square checkbox state
## Setting keyframes
Same interaction as today: click the crop bar while in lock mode. The click now snapshots the current center, portrait combo selection, rand_portrait checkbox, and rand_square checkbox into the keyframe.
## Export application
For each sub-clip job:
1. Find the latest keyframe where `kt <= start_time + 0.05`.
2. Apply its `center` and `ratio` to the job.
3. Collect the effective `rand_portrait` and `rand_square` flags.
4. After all keyframes are resolved, apply random crop selection only to sub-clips whose effective flags are set. The random selection (`n_random = max(1, eligible_count // 3)`) operates within each flag group independently.
When no keyframes exist, behavior is unchanged (global checkboxes apply to all clips).
## Timeline diamond colors
Each keyframe diamond on the timeline is color-coded by its random crop flags:
- No random flags — gold (current color, `#ffb400`)
- Portrait only — red (`QColor(220, 60, 60)`)
- Square only — blue (`QColor(60, 180, 220)`)
- Both — split diamond: left half red, right half blue
## Playback preview in lock mode
When scrubbing in lock mode, `_on_seek_changed` already updates the crop bar preview from keyframes. This extends to also update the portrait combo and random checkboxes to reflect the effective state at the current playback position, so the user sees what each region's settings are.
## Clearing
Toggling lock off clears all keyframes (existing behavior, unchanged).
@@ -0,0 +1,634 @@
# Keyframe Crop Modes Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Extend crop keyframes to snapshot ratio and random crop flags, so different sub-clips in a batch inherit different crop settings based on timeline position.
**Architecture:** Widen the keyframe tuple from `(time, center)` to `(time, center, ratio, rand_portrait, rand_square)`. All existing keyframe code paths (set, delete, clear, render, apply-at-export, preview-on-scrub) are updated to carry and use the new fields. Diamond rendering on the timeline uses color to indicate which random flags are set.
**Tech Stack:** Python, PyQt6 (QPainter for diamonds)
---
### Task 1: Add a helper to resolve the effective keyframe at a given time
The keyframe lookup pattern (iterate sorted list, take latest where `kt <= t + 0.05`) is repeated 3 times in main.py. Extract it as a pure function so we can test it and reuse it cleanly with the new wider tuple.
**Files:**
- Modify: `main.py:48-53` (module-level functions area)
- Test: `tests/test_utils.py`
**Step 1: Write the failing tests**
Add to `tests/test_utils.py`:
```python
from main import resolve_keyframe
def test_resolve_keyframe_empty():
assert resolve_keyframe([], 5.0) is None
def test_resolve_keyframe_before_first():
kfs = [(3.0, 0.5, None, False, False)]
assert resolve_keyframe(kfs, 1.0) is None
def test_resolve_keyframe_exact():
kfs = [(2.0, 0.3, "9:16", True, False)]
assert resolve_keyframe(kfs, 2.0) == (2.0, 0.3, "9:16", True, False)
def test_resolve_keyframe_between():
kfs = [
(1.0, 0.2, None, False, False),
(5.0, 0.8, "1:1", False, True),
]
assert resolve_keyframe(kfs, 3.0) == (1.0, 0.2, None, False, False)
def test_resolve_keyframe_after_last():
kfs = [
(1.0, 0.2, None, False, False),
(5.0, 0.8, "1:1", False, True),
]
assert resolve_keyframe(kfs, 10.0) == (5.0, 0.8, "1:1", False, True)
def test_resolve_keyframe_tolerance():
kfs = [(4.0, 0.5, None, True, True)]
# 4.0 <= 3.96 + 0.05 = 4.01, so it should match
assert resolve_keyframe(kfs, 3.96) == (4.0, 0.5, None, True, True)
```
**Step 2: Run tests to verify they fail**
Run: `pytest tests/test_utils.py -k resolve_keyframe -v`
Expected: FAIL (ImportError — function does not exist yet)
**Step 3: Write the implementation**
Add to `main.py` after the `format_time` function (around line 53):
```python
def resolve_keyframe(
keyframes: list[tuple[float, float, str | None, bool, bool]],
t: float,
tolerance: float = 0.05,
) -> tuple[float, float, str | None, bool, bool] | None:
"""Return the latest keyframe at or before *t*, or None."""
result = None
for kf in keyframes:
if kf[0] <= t + tolerance:
result = kf
else:
break
return result
```
**Step 4: Run tests to verify they pass**
Run: `pytest tests/test_utils.py -k resolve_keyframe -v`
Expected: 6 PASS
**Step 5: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: add resolve_keyframe helper for widened keyframe tuples"
```
---
### Task 2: Widen keyframe tuple and update storage
Change `_crop_keyframes` from `list[tuple[float, float]]` to `list[tuple[float, float, str | None, bool, bool]]` in both `TimelineWidget` and `MainWindow`. Update `set_crop_keyframes` signature.
**Files:**
- Modify: `main.py:735` (TimelineWidget._crop_keyframes)
- Modify: `main.py:786-787` (TimelineWidget.set_crop_keyframes)
- Modify: `main.py:1755` (MainWindow._crop_keyframes)
**Step 1: Update TimelineWidget**
At line 735, change:
```python
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center)]
```
to:
```python
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
```
At lines 786-787, change:
```python
def set_crop_keyframes(self, kfs: list[tuple[float, float]]) -> None:
self._crop_keyframes = kfs
```
to:
```python
def set_crop_keyframes(self, kfs: list[tuple[float, float, str | None, bool, bool]]) -> None:
self._crop_keyframes = kfs
```
**Step 2: Update MainWindow**
At line 1755, change:
```python
self._crop_keyframes: list[tuple[float, float]] = [] # [(time, center), ...] sorted
```
to:
```python
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] # sorted by time
```
**Step 3: Run existing tests**
Run: `pytest tests/ -v`
Expected: All 46 + 6 new = 52 PASS (no existing tests touch keyframes directly)
**Step 4: Commit**
```bash
git add main.py
git commit -m "refactor: widen keyframe tuple to carry ratio and random flags"
```
---
### Task 3: Update keyframe creation to snapshot crop state
When the user clicks the crop bar in lock mode, snapshot the current ratio, rand_portrait, and rand_square into the keyframe.
**Files:**
- Modify: `main.py:2519-2538` (_on_crop_click lock-mode branch)
**Step 1: Update the keyframe creation code**
At lines 2525-2532, change:
```python
self._crop_keyframes = [
(t, c) for t, c in self._crop_keyframes
if abs(t - play_t) > 0.05
]
self._crop_keyframes.append((play_t, frac))
self._crop_keyframes.sort()
self._timeline.set_crop_keyframes(self._crop_keyframes)
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ({len(self._crop_keyframes)} total)")
```
to:
```python
ratio_text = self._cmb_portrait.currentText()
kf_ratio = None if ratio_text == "Off" else ratio_text
kf_rand_p = self._chk_rand_portrait.isChecked()
kf_rand_s = self._chk_rand_square.isChecked()
self._crop_keyframes = [
kf for kf in self._crop_keyframes
if abs(kf[0] - play_t) > 0.05
]
self._crop_keyframes.append((play_t, frac, kf_ratio, kf_rand_p, kf_rand_s))
self._crop_keyframes.sort()
self._timeline.set_crop_keyframes(self._crop_keyframes)
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s} ({len(self._crop_keyframes)} total)")
```
**Step 2: Update keyframe deletion filter**
At lines 2356-2357, change:
```python
self._crop_keyframes = [
(t, c) for t, c in self._crop_keyframes
if abs(t - time) > 0.05
]
```
to:
```python
self._crop_keyframes = [
kf for kf in self._crop_keyframes
if abs(kf[0] - time) > 0.05
]
```
**Step 3: Run existing tests**
Run: `pytest tests/ -v`
Expected: All 52 PASS
**Step 4: Commit**
```bash
git add main.py
git commit -m "feat: snapshot ratio and random flags into crop keyframes"
```
---
### Task 4: Update export to apply full keyframe state
Replace the keyframe application loop and random crop logic in `_on_export` to use the new fields.
**Files:**
- Modify: `main.py:2754-2782` (keyframe application + random crop logic in _on_export)
- Test: `tests/test_utils.py`
**Step 1: Write a test for the export keyframe resolution logic**
Add to `tests/test_utils.py`:
```python
from main import apply_keyframes_to_jobs
def test_apply_keyframes_no_keyframes():
jobs = [(0.0, "/out/a", None, 0.5), (3.0, "/out/b", None, 0.5)]
result = apply_keyframes_to_jobs(jobs, [], base_center=0.5, base_ratio=None,
base_rand_p=True, base_rand_s=False)
# No keyframes: jobs get base values; rand flags come from base
assert result == [
(0.0, "/out/a", None, 0.5, True, False),
(3.0, "/out/b", None, 0.5, True, False),
]
def test_apply_keyframes_with_keyframes():
kfs = [
(0.0, 0.3, "9:16", True, False),
(4.0, 0.7, None, False, True),
]
jobs = [
(0.0, "/out/a", None, 0.5),
(3.0, "/out/b", None, 0.5),
(6.0, "/out/c", None, 0.5),
]
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio=None,
base_rand_p=False, base_rand_s=False)
assert result == [
(0.0, "/out/a", "9:16", 0.3, True, False),
(3.0, "/out/b", "9:16", 0.3, True, False),
(6.0, "/out/c", None, 0.7, False, True),
]
def test_apply_keyframes_before_first_uses_base():
kfs = [(5.0, 0.8, "1:1", False, True)]
jobs = [(1.0, "/out/a", None, 0.5)]
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio="4:5",
base_rand_p=True, base_rand_s=False)
assert result == [(1.0, "/out/a", "4:5", 0.5, True, False)]
```
**Step 2: Run tests to verify they fail**
Run: `pytest tests/test_utils.py -k apply_keyframes -v`
Expected: FAIL (ImportError)
**Step 3: Write the apply_keyframes_to_jobs function**
Add to `main.py` after `resolve_keyframe`:
```python
def apply_keyframes_to_jobs(
jobs: list[tuple[float, str, str | None, float]],
keyframes: list[tuple[float, float, str | None, bool, bool]],
base_center: float,
base_ratio: str | None,
base_rand_p: bool,
base_rand_s: bool,
) -> list[tuple[float, str, str | None, float, bool, bool]]:
"""Resolve each job's crop state from keyframes, returning widened tuples.
Returns list of (start, path, ratio, center, rand_portrait, rand_square).
"""
result = []
for s, o, _r, _c in jobs:
kf = resolve_keyframe(keyframes, s)
if kf is not None:
_, center, ratio, rp, rs = kf
else:
center, ratio, rp, rs = base_center, base_ratio, base_rand_p, base_rand_s
result.append((s, o, ratio, center, rp, rs))
return result
```
**Step 4: Run tests to verify they pass**
Run: `pytest tests/test_utils.py -k apply_keyframes -v`
Expected: 3 PASS
**Step 5: Update _on_export to use the new functions**
Replace lines 2754-2782 (the keyframe application block + random crop block) with:
```python
# Apply crop keyframes (or fall back to base state).
rand_portrait = self._chk_rand_portrait.isChecked()
rand_square = self._chk_rand_square.isChecked()
widened = apply_keyframes_to_jobs(
jobs, self._crop_keyframes,
base_center=base_center, base_ratio=base_ratio,
base_rand_p=rand_portrait, base_rand_s=rand_square,
)
# Random crop: for each clip whose effective flags are set,
# ~1 in 3 gets a random ratio applied.
final_jobs = []
# Collect indices eligible for random crop, grouped by flag combo.
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
square_eligible = [i for i, w in enumerate(widened) if w[5]]
rand_indices: set[int] = set()
if portrait_eligible and n_clips > 1:
n = max(1, len(portrait_eligible) // 3)
rand_indices.update(random.sample(portrait_eligible, min(n, len(portrait_eligible))))
if square_eligible and n_clips > 1:
n = max(1, len(square_eligible) // 3)
rand_indices.update(random.sample(square_eligible, min(n, len(square_eligible))))
for i, (s, o, ratio, center, rp, rs) in enumerate(widened):
if i in rand_indices:
pool = []
if rp:
pool.append("9:16")
if rs:
pool.append("1:1")
if pool:
ratio = random.choice(pool)
jobs.append((s, o, ratio, center))
# Replace jobs with the resolved list.
jobs = jobs[n_clips:] # drop the original entries, keep the new ones
```
Note: `jobs` was built with `n_clips` entries in the loop above. We append resolved entries and then slice off the originals.
Actually, a cleaner rewrite of the tail — replace the entire block from the keyframe comment through the random crop block with:
```python
# Apply crop keyframes (or fall back to base state).
rand_portrait = self._chk_rand_portrait.isChecked()
rand_square = self._chk_rand_square.isChecked()
widened = apply_keyframes_to_jobs(
jobs, self._crop_keyframes,
base_center=base_center, base_ratio=base_ratio,
base_rand_p=rand_portrait, base_rand_s=rand_square,
)
# Random crop: eligible clips (per their keyframe flags) have
# ~1 in 3 chance of getting a random ratio applied.
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
square_eligible = [i for i, w in enumerate(widened) if w[5]]
rand_indices: dict[int, list[str]] = {}
if portrait_eligible and n_clips > 1:
n = max(1, len(portrait_eligible) // 3)
for i in random.sample(portrait_eligible, min(n, len(portrait_eligible))):
rand_indices.setdefault(i, []).append("9:16")
if square_eligible and n_clips > 1:
n = max(1, len(square_eligible) // 3)
for i in random.sample(square_eligible, min(n, len(square_eligible))):
rand_indices.setdefault(i, []).append("1:1")
jobs = []
for i, (s, o, ratio, center, _rp, _rs) in enumerate(widened):
if i in rand_indices:
ratio = random.choice(rand_indices[i])
jobs.append((s, o, ratio, center))
```
**Step 6: Run all tests**
Run: `pytest tests/ -v`
Expected: All PASS
**Step 7: Commit**
```bash
git add main.py tests/test_utils.py
git commit -m "feat: apply keyframe crop modes during export"
```
---
### Task 5: Update diamond rendering with color coding
Color-code timeline keyframe diamonds based on their random flags.
**Files:**
- Modify: `main.py:898-910` (TimelineWidget.paintEvent keyframe diamond section)
**Step 1: Replace the diamond rendering block**
Replace lines 898-910:
```python
# ── crop keyframe diamonds ────────────────────────────────────
if self._crop_keyframes and self._duration > 0:
for (kt, _kc) in self._crop_keyframes:
kx = int(kt / self._duration * w)
d = 4 # half-size of diamond
ky = h - d - 2 # near bottom of track
diamond = QPolygon([
QPoint(kx, ky - d), QPoint(kx + d, ky),
QPoint(kx, ky + d), QPoint(kx - d, ky),
])
p.setBrush(QColor(255, 180, 0))
p.setPen(Qt.PenStyle.NoPen)
p.drawPolygon(diamond)
```
with:
```python
# ── crop keyframe diamonds ────────────────────────────────────
if self._crop_keyframes and self._duration > 0:
_KF_GOLD = QColor(255, 180, 0)
_KF_RED = QColor(220, 60, 60)
_KF_BLUE = QColor(60, 180, 220)
for kf in self._crop_keyframes:
kt = kf[0]
rp = kf[3] if len(kf) > 3 else False
rs = kf[4] if len(kf) > 4 else False
kx = int(kt / self._duration * w)
d = 4 # half-size of diamond
ky = h - d - 2 # near bottom of track
if rp and rs:
# Split diamond: left half red, right half blue
left = QPolygon([
QPoint(kx, ky - d), QPoint(kx, ky + d),
QPoint(kx - d, ky),
])
right = QPolygon([
QPoint(kx, ky - d), QPoint(kx + d, ky),
QPoint(kx, ky + d),
])
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(_KF_RED)
p.drawPolygon(left)
p.setBrush(_KF_BLUE)
p.drawPolygon(right)
else:
diamond = QPolygon([
QPoint(kx, ky - d), QPoint(kx + d, ky),
QPoint(kx, ky + d), QPoint(kx - d, ky),
])
if rp:
color = _KF_RED
elif rs:
color = _KF_BLUE
else:
color = _KF_GOLD
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(color)
p.drawPolygon(diamond)
```
**Step 2: Update the context menu keyframe hit detection**
At line 980, change:
```python
for (kt, _kc) in self._crop_keyframes:
```
to:
```python
for kf in self._crop_keyframes:
kt = kf[0]
```
And remove the `_kc` reference — use `kf[0]` for `kt` only. The rest of the hit-detection logic stays the same.
**Step 3: Run all tests**
Run: `pytest tests/ -v`
Expected: All PASS
**Step 4: Manual test**
Launch the app, load a video, enable lock mode, set keyframes with different combinations of random portrait/square. Verify:
- Gold diamond when no random flags set
- Red diamond when only portrait
- Blue diamond when only square
- Split red/blue when both
**Step 5: Commit**
```bash
git add main.py
git commit -m "feat: color-code keyframe diamonds by crop mode"
```
---
### Task 6: Update lock-mode scrub preview
When scrubbing in lock mode, update the crop bar, overlay, and (visually) the random checkboxes to reflect the effective keyframe state at the playback position.
**Files:**
- Modify: `main.py:2605-2621` (_on_seek_changed)
**Step 1: Replace the keyframe preview block**
Replace lines 2610-2621:
```python
if self._crop_keyframes:
center = self._crop_center
for kt, kc in self._crop_keyframes:
if kt <= t + 0.05:
center = kc
else:
break
self._crop_bar.set_crop_center(center)
ratio = self._cmb_portrait.currentText()
if ratio != "Off":
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
```
with:
```python
if self._crop_keyframes:
kf = resolve_keyframe(self._crop_keyframes, t)
if kf is not None:
_, center, ratio, rp, rs = kf
self._crop_bar.set_crop_center(center)
if ratio is not None:
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
else:
self._update_rand_overlays()
```
**Step 2: Run all tests**
Run: `pytest tests/ -v`
Expected: All PASS
**Step 3: Commit**
```bash
git add main.py
git commit -m "feat: preview effective keyframe crop state during lock-mode scrub"
```
---
### Task 7: Update overwrite-mode keyframe application
The overwrite path (lines 2727-2738) also builds jobs. It doesn't currently apply keyframes, but should for consistency.
**Files:**
- Modify: `main.py:2727-2738` (overwrite branch in _on_export)
**Step 1: Check and update**
After the overwrite jobs are built, apply the same `apply_keyframes_to_jobs` logic if keyframes exist. The overwrite branch builds `jobs` as `(start, path, base_ratio, base_center)` — same shape as the normal path.
Add after line 2738 (`self._overwrite_group = []`):
```python
rand_portrait = self._chk_rand_portrait.isChecked()
rand_square = self._chk_rand_square.isChecked()
if self._crop_keyframes:
widened = apply_keyframes_to_jobs(
jobs, self._crop_keyframes,
base_center=base_center, base_ratio=base_ratio,
base_rand_p=rand_portrait, base_rand_s=rand_square,
)
jobs = [(s, o, r, c) for s, o, r, c, _rp, _rs in widened]
```
**Step 2: Run all tests**
Run: `pytest tests/ -v`
Expected: All PASS
**Step 3: Commit**
```bash
git add main.py
git commit -m "feat: apply keyframe crop modes in overwrite exports too"
```
---
### Task 8: Update import in test file and final validation
**Files:**
- Modify: `tests/test_utils.py:2` (import line)
**Step 1: Update imports**
At line 2, add the new functions to the import:
```python
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, build_annotation_json_path, upsert_clip_annotation, resolve_keyframe, apply_keyframes_to_jobs
```
(This should already be done incrementally in Tasks 1 and 4, but verify it's correct.)
**Step 2: Run full test suite**
Run: `pytest tests/ -v`
Expected: All 55 tests PASS (46 original + 6 resolve_keyframe + 3 apply_keyframes)
**Step 3: Manual integration test**
1. Launch `python main.py`, load a video
2. Enable lock mode (G or click lock button)
3. Scrub to a position, enable "1 random portrait", click crop bar → red diamond appears
4. Scrub forward, disable portrait, enable "1 random square", click crop bar → blue diamond appears
5. Scrub forward, enable both, click crop bar → split red/blue diamond
6. Set clip count to 6+, spread to 2s, export
7. Verify that sub-clips falling in each keyframe region get the correct random crop behavior
8. Right-click a diamond to delete it — verify it disappears
**Step 4: Commit**
```bash
git add tests/test_utils.py
git commit -m "test: verify imports for keyframe crop mode helpers"
```
Regular → Executable
+2448 -577
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
PyQt6>=6.4
python-mpv>=1.0
pytest>=7.0
ultralytics>=8.0
+59
View File
@@ -0,0 +1,59 @@
# 8-cut Windows setup script
# Run once: powershell -ExecutionPolicy Bypass -File setup-windows.ps1
#
# Prerequisites: Python 3.10+ must be installed and on PATH
# https://www.python.org/downloads/
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
Write-Host "=== 8-cut Windows Setup ===" -ForegroundColor Cyan
# ── Python deps ────────────────────────────────────────────
Write-Host "`nInstalling Python dependencies..."
pip install PyQt6 python-mpv
# ── libmpv ─────────────────────────────────────────────────
$mpvDll = Join-Path $root "libmpv-2.dll"
if (Test-Path $mpvDll) {
Write-Host "`nlibmpv-2.dll already present, skipping." -ForegroundColor Green
} else {
Write-Host "`nDownloading libmpv..."
$release = Invoke-RestMethod "https://api.github.com/repos/shinchiro/mpv-winbuild-cmake/releases/latest"
$asset = $release.assets | Where-Object { $_.name -like "mpv-dev-x86_64-v3-*" } | Select-Object -First 1
$tmpFile = Join-Path $root "mpv-dev.7z"
Invoke-WebRequest $asset.browser_download_url -OutFile $tmpFile
7z x $tmpFile -o"$root\mpv-dev" -y | Out-Null
Copy-Item "$root\mpv-dev\libmpv-2.dll" $root
Remove-Item $tmpFile -Force
Remove-Item "$root\mpv-dev" -Recurse -Force
Write-Host "libmpv-2.dll downloaded." -ForegroundColor Green
}
# ── ffmpeg ─────────────────────────────────────────────────
$ffmpeg = Join-Path $root "ffmpeg.exe"
if (Test-Path $ffmpeg) {
Write-Host "`nffmpeg.exe already present, skipping." -ForegroundColor Green
} else {
# Check if ffmpeg is on PATH
$onPath = Get-Command ffmpeg -ErrorAction SilentlyContinue
if ($onPath) {
Write-Host "`nffmpeg found on PATH: $($onPath.Source)" -ForegroundColor Green
} else {
Write-Host "`nDownloading ffmpeg..."
$ffUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
$tmpZip = Join-Path $root "ffmpeg.zip"
Invoke-WebRequest $ffUrl -OutFile $tmpZip
Expand-Archive $tmpZip -DestinationPath "$root\ffmpeg-tmp" -Force
$bin = Get-ChildItem -Path "$root\ffmpeg-tmp" -Recurse -Filter ffmpeg.exe | Select-Object -First 1
Copy-Item "$($bin.DirectoryName)\ffmpeg.exe" $root
Copy-Item "$($bin.DirectoryName)\ffprobe.exe" $root
Remove-Item $tmpZip -Force
Remove-Item "$root\ffmpeg-tmp" -Recurse -Force
Write-Host "ffmpeg.exe downloaded." -ForegroundColor Green
}
}
Write-Host "`n=== Setup complete ===" -ForegroundColor Cyan
Write-Host "Run 8-cut with: python main.py"
Write-Host "Or double-click: 8cut.bat"
+244 -84
View File
@@ -1,16 +1,24 @@
import tempfile, os
from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir, build_audio_extract_command, build_annotation_tsv_path, append_to_tsv
from main import _normalize_filename, ProcessedDB
import tempfile, os, json
from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, build_annotation_json_path, upsert_clip_annotation, resolve_keyframe, apply_keyframes_to_jobs
from main import ProcessedDB
def test_build_export_path_first():
assert build_export_path("/out", "clip", 1) == "/out/clip_001.mp4"
assert build_export_path("/out", "clip", 1) == "/out/clip_001/clip_001.mp4"
def test_build_export_path_counter():
assert build_export_path("/out", "clip", 42) == "/out/clip_042.mp4"
assert build_export_path("/out", "clip", 42) == "/out/clip_042/clip_042.mp4"
def test_build_export_path_deep_counter():
assert build_export_path("/out", "shot", 999) == "/out/shot_999.mp4"
assert build_export_path("/out", "shot", 999) == "/out/shot_999/shot_999.mp4"
def test_build_export_path_sub():
assert build_export_path("/out", "clip", 1, sub=0) == "/out/clip_001/clip_001_0.mp4"
assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001/clip_001_2.mp4"
def test_build_sequence_dir_sub():
assert build_sequence_dir("/out", "clip", 1, sub=0) == "/out/clip_001/clip_001_0"
assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001/clip_001_1"
def test_format_time_seconds():
assert format_time(0.0) == "0:00.0"
@@ -45,63 +53,47 @@ def test_ffmpeg_command_with_resize():
assert cmd[-1] == "/out/clip_001.mp4"
# --- _normalize_filename ---
def test_normalize_strips_extension():
assert _normalize_filename("clip.mp4") == "clip"
def test_normalize_strips_resolution():
assert _normalize_filename("clip_2160p.mp4") == "clip"
def test_normalize_strips_1080p():
assert _normalize_filename("clip_1080p.mkv") == "clip"
def test_normalize_strips_multiple_tags():
assert _normalize_filename("show_1080p_HDR.mkv") == "show"
def test_normalize_lowercases():
assert _normalize_filename("MyVideo_4K.mp4") == "myvideo"
def test_normalize_collapses_separators():
assert _normalize_filename("my__video--2160p.mp4") == "my_video"
# --- ProcessedDB ---
def test_db_add_and_find_exact():
def test_db_add_and_get_markers():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 12.5, "/out/clip_001.mp4")
assert db.find_similar("video.mp4") == "video.mp4"
markers = db.get_markers("video.mp4")
assert len(markers) == 1
assert markers[0][0] == 12.5
finally:
os.unlink(path)
def test_db_find_similar_resolution_variant():
def test_db_exact_match_only():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("episode_s01e01_2160p.mkv", 0.0, "/out/ep_001.mp4")
assert db.find_similar("episode_s01e01_1080p.mkv") == "episode_s01e01_2160p.mkv"
# Different filename — no match even if similar
assert db.get_markers("episode_s01e01_1080p.mkv") == []
# Exact filename — match
assert len(db.get_markers("episode_s01e01_2160p.mkv")) == 1
finally:
os.unlink(path)
def test_db_find_similar_no_match():
def test_db_no_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("alpha.mp4", 0.0, "/out/alpha_001.mp4")
assert db.find_similar("completely_different_zzzz.mp4") is None
assert db.get_markers("completely_different.mp4") == []
finally:
os.unlink(path)
def test_db_disabled_survives_bad_path():
db = ProcessedDB("/no/such/directory/8cut.db")
db.add("x.mp4", 0.0, "/out/x_001.mp4") # must not raise
assert db.find_similar("x.mp4") is None
assert db.get_markers("x.mp4") == []
def test_db_get_markers_returns_sorted():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
@@ -119,19 +111,6 @@ def test_db_get_markers_returns_sorted():
finally:
os.unlink(path)
def test_db_get_markers_fuzzy_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("show_2160p.mkv", 5.0, "/out/s_001.mp4")
markers = db.get_markers("show_1080p.mkv")
assert len(markers) == 1
assert markers[0][0] == 5.0
assert markers[0][2] == "/out/s_001.mp4"
finally:
os.unlink(path)
def test_db_get_markers_no_match():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
@@ -174,16 +153,6 @@ def test_ffmpeg_command_portrait_off():
cmd = build_ffmpeg_command("/in/video.mp4", 0.0, "/out/clip.mp4")
assert "-vf" not in cmd
def test_mask_output_dir_basic():
assert build_mask_output_dir("/out/clip_001.mp4") == "/out/clip_001_masks"
def test_mask_output_dir_mkv():
assert build_mask_output_dir("/out/my_clip.mkv") == "/out/my_clip_masks"
def test_mask_output_dir_nested():
assert build_mask_output_dir("/a/b/c/shot_042.mp4") == "/a/b/c/shot_042_masks"
# --- build_audio_extract_command ---
def test_audio_extract_output_path():
@@ -208,19 +177,16 @@ def test_audio_extract_timing():
def test_build_sequence_dir_basic():
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001"
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001/clip_001"
def test_build_sequence_dir_counter():
assert build_sequence_dir("/out", "clip", 42) == "/out/clip_042"
assert build_sequence_dir("/out", "clip", 42) == "/out/clip_042/clip_042"
def test_ffmpeg_command_image_sequence():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True)
assert "-c:v" in cmd
assert cmd[cmd.index("-c:v") + 1] == "libwebp"
assert "-lossless" in cmd
assert cmd[cmd.index("-lossless") + 1] == "1"
assert "-compression_level" in cmd
assert cmd[cmd.index("-compression_level") + 1] == "4"
assert "-quality" in cmd
assert cmd[-1] == "/out/seq_001/frame_%04d.webp"
def test_ffmpeg_command_image_sequence_with_resize():
@@ -237,28 +203,47 @@ def test_ffmpeg_command_image_sequence_no_audio():
assert "aac" not in cmd
def test_annotation_tsv_path():
assert build_annotation_tsv_path("/out") == "/out/dataset.tsv"
def test_annotation_json_path():
assert build_annotation_json_path("/out") == "/out/dataset.json"
def test_append_to_tsv_creates_file():
def test_upsert_creates_file():
with tempfile.TemporaryDirectory() as d:
append_to_tsv(d, "clip_001", "dog barking")
with open(os.path.join(d, "dataset.tsv")) as f:
lines = f.readlines()
assert lines == ["clip_001\tdog barking\n"]
clip = os.path.join(d, "clip_001.mp4")
upsert_clip_annotation(d, clip, "dog barking")
with open(os.path.join(d, "dataset.json")) as f:
entries = json.load(f)
assert len(entries) == 1
assert entries[0]["label"] == "dog barking"
assert entries[0]["path"] == clip
def test_append_to_tsv_appends():
def test_upsert_appends_new_clips():
with tempfile.TemporaryDirectory() as d:
append_to_tsv(d, "clip_001", "dog barking")
append_to_tsv(d, "clip_002", "cat meowing")
with open(os.path.join(d, "dataset.tsv")) as f:
lines = f.readlines()
assert len(lines) == 2
upsert_clip_annotation(d, os.path.join(d, "clip_001.mp4"), "dog barking")
upsert_clip_annotation(d, os.path.join(d, "clip_002.mp4"), "cat meowing")
with open(os.path.join(d, "dataset.json")) as f:
entries = json.load(f)
assert len(entries) == 2
def test_append_to_tsv_empty_label_skips():
def test_upsert_replaces_existing():
with tempfile.TemporaryDirectory() as d:
append_to_tsv(d, "clip_001", "")
assert not os.path.exists(os.path.join(d, "dataset.tsv"))
clip = os.path.join(d, "clip_001.mp4")
upsert_clip_annotation(d, clip, "dog barking")
upsert_clip_annotation(d, clip, "cat meowing")
with open(os.path.join(d, "dataset.json")) as f:
entries = json.load(f)
assert len(entries) == 1
assert entries[0]["label"] == "cat meowing"
def test_upsert_empty_label_skips():
with tempfile.TemporaryDirectory() as d:
upsert_clip_annotation(d, os.path.join(d, "clip_001.mp4"), "")
assert not os.path.exists(os.path.join(d, "dataset.json"))
def test_upsert_missing_folder_creates_it():
with tempfile.TemporaryDirectory() as d:
nested = os.path.join(d, "subdir", "deep")
upsert_clip_annotation(nested, os.path.join(nested, "clip_001.mp4"), "dog barking")
assert os.path.exists(os.path.join(nested, "dataset.json"))
def test_db_stores_label_and_category():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
@@ -273,8 +258,183 @@ def test_db_stores_label_and_category():
finally:
os.unlink(path)
def test_append_to_tsv_missing_folder_creates_it():
with tempfile.TemporaryDirectory() as d:
nested = os.path.join(d, "subdir", "deep")
append_to_tsv(nested, "clip_001", "dog barking")
assert os.path.exists(os.path.join(nested, "dataset.tsv"))
def test_db_get_group_returns_all_sub_clips():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_2.mp4")
group = db.get_group("/out/clip_001/clip_001_0.mp4")
assert len(group) == 3
assert "/out/clip_001/clip_001_0.mp4" in group
assert "/out/clip_001/clip_001_2.mp4" in group
finally:
os.unlink(path)
def test_db_get_group_isolates_by_start_time():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
db.add("video.mp4", 30.0, "/out/clip_002/clip_002_0.mp4")
group = db.get_group("/out/clip_001/clip_001_0.mp4")
assert len(group) == 2
finally:
os.unlink(path)
def test_db_delete_group_removes_all():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
db.add("video.mp4", 30.0, "/out/clip_002/clip_002_0.mp4")
deleted = db.delete_group("/out/clip_001/clip_001_0.mp4")
assert len(deleted) == 2
# clip_002 should still exist
markers = db.get_markers("video.mp4")
assert len(markers) == 1
assert markers[0][0] == 30.0
finally:
os.unlink(path)
def test_db_get_group_disabled():
db = ProcessedDB("/no/such/directory/8cut.db")
assert db.get_group("/out/clip_001.mp4") == []
def test_db_delete_group_disabled():
db = ProcessedDB("/no/such/directory/8cut.db")
assert db.delete_group("/out/clip_001.mp4") == []
# --- Profiles ---
def test_db_markers_isolated_by_profile():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 10.0, "/out/a_001.mp4", profile="landscape")
db.add("video.mp4", 20.0, "/out/b_001.mp4", profile="portrait")
land = db.get_markers("video.mp4", profile="landscape")
port = db.get_markers("video.mp4", profile="portrait")
assert len(land) == 1
assert land[0][0] == 10.0
assert len(port) == 1
assert port[0][0] == 20.0
finally:
os.unlink(path)
def test_db_get_profiles():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
assert db.get_profiles() == []
db.add("a.mp4", 0.0, "/out/a.mp4", profile="beta")
db.add("b.mp4", 0.0, "/out/b.mp4", profile="alpha")
db.add("c.mp4", 0.0, "/out/c.mp4", profile="beta")
profiles = db.get_profiles()
assert profiles == ["alpha", "beta"]
finally:
os.unlink(path)
def test_db_get_profiles_disabled():
db = ProcessedDB("/no/such/directory/8cut.db")
assert db.get_profiles() == []
def test_db_default_profile_backward_compat():
"""Existing tests pass without explicit profile — defaults to 'default'."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
try:
db = ProcessedDB(path)
db.add("video.mp4", 5.0, "/out/clip.mp4")
markers = db.get_markers("video.mp4") # no profile arg
assert len(markers) == 1
assert markers[0][0] == 5.0
assert db.get_profiles() == ["default"]
finally:
os.unlink(path)
# --- resolve_keyframe ---
def test_resolve_keyframe_empty():
assert resolve_keyframe([], 5.0) is None
def test_resolve_keyframe_before_first():
kfs = [(3.0, 0.5, None, False, False)]
assert resolve_keyframe(kfs, 1.0) is None
def test_resolve_keyframe_exact():
kfs = [(2.0, 0.3, "9:16", True, False)]
assert resolve_keyframe(kfs, 2.0) == (2.0, 0.3, "9:16", True, False)
def test_resolve_keyframe_between():
kfs = [
(1.0, 0.2, None, False, False),
(5.0, 0.8, "1:1", False, True),
]
assert resolve_keyframe(kfs, 3.0) == (1.0, 0.2, None, False, False)
def test_resolve_keyframe_after_last():
kfs = [
(1.0, 0.2, None, False, False),
(5.0, 0.8, "1:1", False, True),
]
assert resolve_keyframe(kfs, 10.0) == (5.0, 0.8, "1:1", False, True)
def test_resolve_keyframe_tolerance():
kfs = [(4.0, 0.5, None, True, True)]
assert resolve_keyframe(kfs, 3.96) == (4.0, 0.5, None, True, True)
# --- apply_keyframes_to_jobs ---
def test_apply_keyframes_no_keyframes():
jobs = [(0.0, "/out/a", None, 0.5), (3.0, "/out/b", None, 0.5)]
result = apply_keyframes_to_jobs(jobs, [], base_center=0.5, base_ratio=None,
base_rand_p=True, base_rand_s=False)
assert result == [
(0.0, "/out/a", None, 0.5, True, False),
(3.0, "/out/b", None, 0.5, True, False),
]
def test_apply_keyframes_with_keyframes():
kfs = [
(0.0, 0.3, "9:16", True, False),
(4.0, 0.7, None, False, True),
]
jobs = [
(0.0, "/out/a", None, 0.5),
(3.0, "/out/b", None, 0.5),
(6.0, "/out/c", None, 0.5),
]
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio=None,
base_rand_p=False, base_rand_s=False)
assert result == [
(0.0, "/out/a", "9:16", 0.3, True, False),
(3.0, "/out/b", "9:16", 0.3, True, False),
(6.0, "/out/c", None, 0.7, False, True),
]
def test_apply_keyframes_before_first_uses_base():
kfs = [(5.0, 0.8, "1:1", False, True)]
jobs = [(1.0, "/out/a", None, 0.5)]
result = apply_keyframes_to_jobs(jobs, kfs, base_center=0.5, base_ratio="4:5",
base_rand_p=True, base_rand_s=False)
assert result == [(1.0, "/out/a", "4:5", 0.5, True, False)]
-75
View File
@@ -1,75 +0,0 @@
"""Depth Anything V2 mask generation script.
Usage:
python tools/depth_masks.py --input video.mp4 --output masks_dir/
Outputs one binary PNG per frame: frame_0000.png, frame_0001.png, …
Foreground = white (255), background = black (0), via Otsu threshold on depth map.
Requires: torch, transformers, opencv-python, Pillow
"""
import argparse
import os
import sys
import cv2
import numpy as np
from PIL import Image
from transformers import pipeline
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
os.makedirs(args.output, exist_ok=True)
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}", flush=True)
pipe = pipeline(
"depth-estimation",
model="depth-anything/Depth-Anything-V2-Large-hf",
device=device,
)
cap = cv2.VideoCapture(args.input)
if not cap.isOpened():
print(f"ERROR: cannot open {args.input}", file=sys.stderr)
sys.exit(1)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
idx = 0
while True:
ret, frame = cap.read()
if not ret:
break
pil_img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
result = pipe(pil_img)
depth = np.array(result["depth"]) # float32 array
# Normalise to 0255
d_min, d_max = depth.min(), depth.max()
if d_max > d_min:
depth_u8 = ((depth - d_min) / (d_max - d_min) * 255).astype(np.uint8)
else:
depth_u8 = np.zeros_like(depth, dtype=np.uint8)
# Otsu threshold: closer objects (higher depth value) = foreground
_, mask = cv2.threshold(depth_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
out_path = os.path.join(args.output, f"frame_{idx:04d}.png")
cv2.imwrite(out_path, mask)
idx += 1
print(f"frame {idx}/{total}", flush=True)
cap.release()
print("done", flush=True)
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""One-shot migration: generate dataset.json files from ~/.8cut.db history.
For each export folder that has records in the database, writes (or merges
into) a dataset.json with path (relative to the folder) and label per clip.
Usage:
python tools/migrate_dataset_json.py [--dry-run]
"""
import argparse
import json
import os
import sqlite3
from collections import defaultdict
from pathlib import Path
DB_PATH = Path.home() / ".8cut.db"
def load_db_records(db_path: Path) -> list[dict]:
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
rows = con.execute(
"SELECT output_path, label FROM processed WHERE label != ''"
).fetchall()
con.close()
return [dict(r) for r in rows]
def build_entries_for_folder(folder: str, records: list[dict]) -> list[dict]:
return [
{"path": os.path.abspath(rec["output_path"]), "label": rec["label"]}
for rec in records
]
def merge_into_json(json_path: str, new_entries: list[dict], dry_run: bool) -> int:
"""Merge new_entries into existing json_path, returning count added/updated."""
existing: list[dict] = []
if os.path.exists(json_path):
with open(json_path, "r", encoding="utf-8") as f:
try:
existing = json.load(f)
except (json.JSONDecodeError, ValueError):
existing = []
by_path = {e["path"]: e for e in existing}
changed = 0
for entry in new_entries:
if by_path.get(entry["path"]) != entry:
by_path[entry["path"]] = entry
changed += 1
merged = list(by_path.values())
if not dry_run and changed:
with open(json_path, "w", encoding="utf-8") as f:
json.dump(merged, f, indent=2, ensure_ascii=False)
f.write("\n")
return changed
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true",
help="Show what would be written without writing")
args = parser.parse_args()
if not DB_PATH.exists():
print(f"Database not found: {DB_PATH}")
return
records = load_db_records(DB_PATH)
if not records:
print("No labelled records in database.")
return
# Group records by parent folder of output_path
by_folder: dict[str, list[dict]] = defaultdict(list)
for rec in records:
folder = os.path.dirname(rec["output_path"])
by_folder[folder].append(rec)
total_written = 0
for folder, recs in sorted(by_folder.items()):
entries = build_entries_for_folder(folder, recs)
json_path = os.path.join(folder, "dataset.json")
changed = merge_into_json(json_path, entries, args.dry_run)
status = "(dry run) " if args.dry_run else ""
print(f"{status}{json_path}: {len(entries)} clips, {changed} added/updated")
total_written += changed
suffix = " (dry run)" if args.dry_run else ""
print(f"\nTotal: {total_written} entries written{suffix}")
if __name__ == "__main__":
main()
-83
View File
@@ -1,83 +0,0 @@
"""SAM2 mask generation script.
Usage:
python tools/sam_masks.py --input video.mp4 --output masks_dir/
Outputs one binary PNG per frame: frame_0000.png, frame_0001.png, …
Uses center of first frame as positive point prompt, propagates across all frames.
Requires: torch, segment-anything-2, opencv-python
"""
import argparse
import os
import sys
import tempfile
import cv2
import numpy as np
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
os.makedirs(args.output, exist_ok=True)
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}", flush=True)
# Extract frames to temp directory (SAM2 video predictor needs image files)
with tempfile.TemporaryDirectory() as frame_dir:
cap = cv2.VideoCapture(args.input)
if not cap.isOpened():
print(f"ERROR: cannot open {args.input}", file=sys.stderr)
sys.exit(1)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
idx = 0
while True:
ret, frame = cap.read()
if not ret:
break
cv2.imwrite(os.path.join(frame_dir, f"{idx:04d}.jpg"), frame)
idx += 1
cap.release()
print(f"Extracted {idx} frames", flush=True)
# SAM2: use from_pretrained (SAM2.1+ / HuggingFace integration)
from sam2.sam2_video_predictor import SAM2VideoPredictor
predictor = SAM2VideoPredictor.from_pretrained(
"facebook/sam2-hiera-large"
).to(device)
with torch.inference_mode():
state = predictor.init_state(video_path=frame_dir)
# Center of first frame as positive point prompt
cx, cy = width // 2, height // 2
_, _, _ = predictor.add_new_points_or_box(
inference_state=state,
frame_idx=0,
obj_id=1,
points=np.array([[cx, cy]], dtype=np.float32),
labels=np.array([1], dtype=np.int32),
)
for frame_idx, obj_ids, out_mask_logits in predictor.propagate_in_video(state):
# out_mask_logits: (N_objects, 1, H, W) — threshold logits at 0
mask = (out_mask_logits[0].squeeze().cpu().numpy() > 0.0).astype(np.uint8) * 255
out_path = os.path.join(args.output, f"frame_{frame_idx:04d}.png")
cv2.imwrite(out_path, mask)
print(f"frame {frame_idx + 1}/{total}", flush=True)
print("done", flush=True)
if __name__ == "__main__":
main()