32 Commits

Author SHA1 Message Date
c90b6765b7 fix: timeline resize regression + dynamic lane alignment
- Remove splitAreaEl dependency from handleResizeMove; use fixed 40-500 clamp
- Add computeLanes() + LaneLayout for dynamic lane height computation
- Update clipRenderer to accept LaneLayout instead of hardcoded constants
- Tighten CONTROLS_HEIGHT (30→22) and CLIP_HEIGHT (30→20) to reduce gaps
- Tighten label baseline offset (14→10) for better waveform-to-tick alignment

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:15:19 -04:00
23b30be357 fix: CSS-grid layout to preserve video on toggle + magnification enhancements (brighter, larger, minor ticks)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 17:30:47 -04:00
124542b4c4 fix: post-smoke-test polish — preserve playhead, clip list redesign, play-clip action, 1100x750 default
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 17:15:28 -04:00
279e27a9b5 docs: user edits to v0.1.3 spec (version bump section)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:54:53 -04:00
8c271773bd feat: timeline right-click context menu with mark/seek/delete actions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:53:43 -04:00
27c199e090 feat: larger timeline timestamps with mouse-proximity magnification
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:51:56 -04:00
923c7b0e23 fix: clip list width edge cases (falsy check, narrow-window clamp)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:50:57 -04:00
b371ec6c3e feat: adjustable clip list layout (bottom/left sidebar)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:49:35 -04:00
0b8bec2496 chore: bump version to 0.1.3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:48:14 -04:00
91550dacd6 docs: add v0.1.3 implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:42:59 -04:00
9752a6d614 docs: add v0.1.3 polish/tweaks design spec
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:32:17 -04:00
9499d776fd fix: about dialog, cache paths, keyframes, reprocess always uses low-res
- Replace native macOS About menu with custom Svelte dialog via Tauri event
- Inline SVG icon in AboutDialog (fixes broken image in webview)
- Align cache directory with Tauri $APPDATA, add to asset protocol scope
- Fix ffprobe 8.x keyframe parsing (skip_frame nokey, flexible parser)
- Copy caption files into cache dir when outside cache path
- Add -y flag to ffmpeg thumbnail extraction (prevent hang on re-extract)
- Reprocess always uses preview (low-res) file, never HQ export
- Prefer cached preview file for analysis even when HQ is cached
- Guard upgrade toast when already on HQ

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:16:00 -04:00
925a3fc054 chore: bump version to 0.1.2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:49:47 -04:00
36ff3094b6 feat: add per-video reprocess button to status bar
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:48:26 -04:00
2c3d035d1f feat: add cache management section to Preferences panel
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:47:29 -04:00
b180d4324c feat: integrate analysis cache into video session processing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:45:37 -04:00
b7a5caf4ae feat: add TypeScript bindings for cache IPC commands
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:44:24 -04:00
6818938cb0 feat: add optional output_dir param to extract_thumbnails
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:43:24 -04:00
b7fb7929f0 feat: add cache IPC commands for analysis caching
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:41:37 -04:00
1773ae81e6 feat: add cache_manager service for analysis caching
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:39:43 -04:00
dc3e7945c6 chore: add sha2 dependency for URL hashing in analysis cache
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:38:16 -04:00
8bd2b34103 feat: auto-use cached HQ video file without upgrade prompt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:36:33 -04:00
90f598050c fix: show empty timeline lanes when no video is loaded
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:34:58 -04:00
2f810111cc docs: add v0.1.2 polish implementation plan
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:31:36 -04:00
2055524d30 docs: add v0.1.2 polish design spec (placeholders, auto-upgrade, analysis cache)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:26:44 -04:00
d46973a719 v0.1.1: fix .app bundle PATH for pyenv/nvm, detect yt-dlp pot-provider plugin
- fix_path_env() now adds ~/.pyenv/shims (with PYENV_ROOT) before
  Homebrew paths so pyenv-managed yt-dlp (with pip-installed plugins)
  takes priority over Homebrew's bare yt-dlp binary
- Also handles ~/.nvm, ~/.deno/bin, ~/.cargo/bin for broader coverage
- check_pot_plugin uses lightweight 'yt-dlp -v' instead of network
  simulation for reliable plugin detection
- Bump version to 0.1.1

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 14:09:23 -04:00
1ea7f19668 feat: add release build script
- scripts/build-release.sh: builds, signs, and copies .app to dist/release/
- Bundle targets narrowed to app + dmg
- npm run release convenience script
- README documents the release build workflow

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 13:39:16 -04:00
30b04b42d4 fix: correct clip in-point edit test for EDIT_TOLERANCE
markInPoint(5) is outside the clip (10-20) with 0.5s tolerance.
Use markInPoint(12) which is inside the clip and triggers the edit path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 13:33:01 -04:00
d85fbdac05 fix: suppress Rust dead code warnings with annotations
- ExportEvent::Error and DownloadEvent::Error reserved for future use
- clip_exporter functions scaffolded for caption burn-in pipeline
- SubtitleFormat fields required for serde deserialization

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 13:32:23 -04:00
9d1f1cfcf3 fix: resolve all Svelte a11y warnings
- Associate labels with controls in CaptionSettingsPanel (9 warnings)
- Add tabindex and keyboard handlers to dialog overlays (8 warnings)
- Add <track> to video element for caption accessibility
- Add keyboard support to resize handle separator

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 13:30:27 -04:00
3eebe01d63 fix: resolve TypeScript errors in vite.config.ts
- Install @types/node for node:path, node:process, node:url
- Import defineConfig from vitest/config for test config type
- Add types: ["node"] to tsconfig.json

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 13:28:05 -04:00
4bf65458d0 docs: add v0.1.0 release polish chat summary
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 12:57:14 -04:00
56 changed files with 6330 additions and 230 deletions

1
.gitignore vendored
View File

@@ -9,5 +9,6 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
dist/
.cursor/
.vscode/

View File

@@ -33,6 +33,16 @@ npm run tauri dev
npm run tauri build
```
## Release Build
```bash
npm run release
```
This runs the full Tauri production build, ad-hoc code signs the `.app` bundle, and copies the output to `dist/release/`.
The resulting `GUI Video Clipper.app` can be launched directly or dragged to `/Applications`.
## Architecture
GUI Video Clipper is built with [Tauri v2](https://v2.tauri.app/) (Rust backend) and [Svelte 5](https://svelte.dev/) (TypeScript frontend).

View File

@@ -1 +1 @@
0.1.0
0.1.3

View File

@@ -0,0 +1,56 @@
# v0.1.0 Release Polish — Summary
## Task Description
Prepared GUI Video Clipper for its initial `v0.1.0` tagged release. This was a polish/branding/release-prep pass — no new features, purely metadata, versioning infrastructure, an About dialog, licensing, README, and pushing to the remote repository with a clean git history.
## Changes Made
### Versioning Infrastructure (Task 1)
- Created `VERSION` file at project root (single source of truth: `0.1.0`)
- Created `scripts/bump-version.sh` that syncs VERSION → `package.json`, `Cargo.toml`, `tauri.conf.json`, and `Cargo.lock`
- Script handles leading `v`/`V`, trailing whitespace, and validates semver format
### Metadata Cleanup (Task 2)
- Replaced all placeholder/template values across the codebase:
- `productName`: "Video Clipper" → "GUI Video Clipper"
- `identifier`: "com.erikfredericks.tauri-app" → "xyz.cottongin.gui-video-clipper"
- `Cargo.toml` name/description/authors/lib.name
- `main.rs` crate reference: `tauri_app_lib` → `gui_video_clipper_lib`
- `index.html` title and SetupWizard body text
### Path Scrubbing (Task 3)
- Replaced 6 absolute paths containing personal username in 3 plan doc files with relative equivalents
### LICENSE (Task 4)
- Added MIT license file (copyright 2026 cottongin)
### README (Task 5)
- Full rewrite replacing stock Tauri template: features list, prerequisites, build instructions, architecture overview, contributing guide, license
### About Dialog (Task 6)
- New `AboutDialog.svelte` component showing app icon, title, version (via `getVersion()`), author, license, repo link
- Three triggers: toolbar ℹ button, ⌘/ shortcut, clickable version label in status bar
- Wired into `App.svelte` and `StatusBar.svelte`
### Git History Scrub & Release (Tasks 7-8)
- Committed all untracked spec/plan docs
- Ran `git-filter-repo --replace-text` in two passes:
1. Path/identifier replacements (com.erikfredericks.tauri-app → xyz.cottongin.gui-video-clipper, absolute paths → empty)
2. Bare username replacement (erikfredericks → REDACTED_USERNAME)
- Final verification: 0 matches for "erikfredericks" across all 49 commits
- Added remote: `git@code.cottongin.xyz:cottongin/gui-video-clipper.git`
- Tagged `v0.1.0` and pushed to `origin/master`
## Lessons Learned
- `git-filter-repo --replace-text` is clean and fast but needs multiple passes when replacements include the target string as documentation (meta-references in spec/plan docs survive the first pass)
- The branch was `master` (from initial scaffold), not `main` — plan assumed `main`
- `npm pkg set version=X --json` emits a JSON parse error on some npm versions before the fallback succeeds — harmless but noisy
- Pre-existing `vite.config.ts` type errors cause `npm run check` to exit non-zero; unrelated to this work
## Follow-Up Items
- Fix pre-existing `vite.config.ts` type errors so `npm run check` passes clean
- Consider renaming `master` → `main` if preferred
- Future: CI/CD pipeline, `.dmg` packaging, changelog generation

View File

@@ -0,0 +1,62 @@
# Build Cleanup & Release Packaging
**Date:** 2026-09-22 13:39
**Commits:** 4bf6545..1ea7f19 (5 commits)
## Task Description
Resolved all build warnings/errors across the frontend (svelte-check, Vite) and backend (cargo), fixed a failing test, and implemented a release build script that produces a distributable `GUI Video Clipper.app` bundle.
## Changes Made
### Task 1: TypeScript Error Fixes (`3eebe01`)
- Installed `@types/node` as dev dependency
- Added `"types": ["node"]` to `tsconfig.json`
- Changed `defineConfig` import in `vite.config.ts` from `vite` to `vitest/config` (extends config type with `test` property)
- Result: 4 TS errors → 0
### Task 2: Svelte A11y Warning Fixes (`9d1f1cf`)
- **CaptionSettingsPanel.svelte**: 9 label/control association fixes (id/for pairs and wrapping)
- **ClipList.svelte**: Added `tabindex="0"` and keyboard handler to listbox container
- **PreferencesPanel.svelte, ExportDialog.svelte, AboutDialog.svelte**: Added `tabindex="-1"` to dialog divs, keyboard handlers on overlays
- **VideoPlayer.svelte**: Added `<track kind="captions" />` to `<video>` element
- **App.svelte**: Added keyboard support to resize handle (`role="slider"`, arrow keys, aria-value attrs)
- Result: 19 a11y warnings → 0
### Task 3: Rust Dead Code Annotations (`d85fbda`)
- Added `#[allow(dead_code)]` with comments on 6 items across 4 files:
- `ExportEvent::Error`, `DownloadEvent::Error` (reserved for future use)
- `sanitize_vtt_for_ffmpeg`, `opacity_to_ass_back_colour`, `caption_style_to_force_style` (caption burn-in pipeline)
- `SubtitleFormat.ext`/`.url` (serde deserialization fields)
- Result: 6 warnings → 0
### Task 4: Test Fix (`30b04b4`)
- Changed `markInPoint(5)` to `markInPoint(12)` in clips.test.ts
- Time 5 was outside `EDIT_TOLERANCE` (0.5s) of clip boundary (10-20), so `isTimeInsideClip` returned false and the edit path didn't execute
- Result: 1 failing test → 31/31 pass
### Task 5: Release Build Script (`1ea7f19`)
- Created `scripts/build-release.sh`: builds, ad-hoc signs, copies to `dist/release/`
- Changed `tauri.conf.json` bundle targets from `"all"` to `["app", "dmg"]`
- Added `dist/` to `.gitignore`
- Added `"release"` npm script
- Updated README with Release Build section
- Script parses tauri build output for .app/.dmg paths (handles custom CARGO_TARGET_DIR)
- Result: `npm run release` → `GUI Video Clipper.app` (6.2M) + `.dmg` (2.8M)
## Verification Results
- `npm run check`: 0 errors, 0 warnings
- `npm test`: 31/31 pass
- `cargo build`: 0 warnings
- `npm run release`: produces signed .app + .dmg
## Lessons Learned
- **Cursor sandbox CARGO_TARGET_DIR**: The Tauri build outputs to a sandboxed cache directory, not `src-tauri/target/`. The build script must parse the actual output paths from `npx tauri build` output rather than hardcoding the expected location.
- **Svelte a11y linter strictness**: `role="separator"` with keyboard support still triggers `a11y_no_noninteractive_element_interactions`. Using `role="slider"` satisfies the linter for resize handles.
- **Dialog overlay keyboard patterns**: Adding `onkeydown` + `tabindex="-1"` to overlays and `e.stopPropagation()` to inner dialogs satisfies the click-needs-keyboard-equivalent rule, but the stopPropagation means Escape from inside the dialog won't bubble to the overlay handler. Not a regression since dialogs previously had no keyboard close.
- **Test EDIT_TOLERANCE awareness**: When testing functions that use proximity checks (`isTimeInsideClip`), test values must account for tolerance thresholds. Values outside tolerance trigger different code paths.
## Follow-up Items
- Push new commits to remote: `git push origin master`
- Consider re-tagging `v0.1.0` to include these cleanup commits, or tag as `v0.1.1`
- Dialog Escape-from-inside behavior could be improved in a future pass (add Escape handler on the dialog div itself, not just the overlay)

View File

@@ -0,0 +1,43 @@
# Fix pyenv pot-provider detection & version bump to v0.1.1
## Task Description
The built `.app` bundle was not detecting the `bgutil-ytdlp-pot-provider` yt-dlp plugin, even after the initial `fix_path_env()` implementation that added Homebrew paths to `PATH`. After fixing, bump version to 0.1.1, commit, push, and tag.
## Root Cause
`fix_path_env()` added `/opt/homebrew/bin` to PATH, which found Homebrew's `yt-dlp` binary. However, the pot-provider plugin was pip-installed under **pyenv**'s Python (`~/.pyenv/versions/3.12.6/lib/python3.12/site-packages/yt_dlp_plugins`). Homebrew's yt-dlp uses a different Python runtime with no plugin directories (`[debug] Plugin directories: none`). The pyenv shim for `yt-dlp` correctly resolves to pyenv's Python where the plugin is installed.
## Changes Made
### `src-tauri/src/lib.rs` — Enhanced `fix_path_env()`
- Added `~/.pyenv/shims` to PATH **before** Homebrew paths so pyenv-managed yt-dlp (with pip plugins) takes priority
- Sets `PYENV_ROOT` env var when pyenv exists so shims resolve correctly
- Added `~/.nvm/versions/node/<latest>/bin` discovery for nvm-managed Node.js
- Added `~/.deno/bin` to handled paths
- Restructured the function for clarity: home-relative paths first, then system-wide paths
### Version Bump (via `scripts/bump-version.sh 0.1.1`)
- `VERSION` → 0.1.1
- `package.json` → 0.1.1
- `src-tauri/Cargo.toml` → 0.1.1
- `src-tauri/tauri.conf.json` → 0.1.1
- `src-tauri/Cargo.lock` regenerated
### Git Operations
- Committed: `d46973a` on `master`
- Pushed to `origin/master`
- Tagged: `v0.1.1` (annotated, pushed)
## Verification
- `npm run check` — 0 errors, 0 warnings
- `npm test` — 5 files, 31 tests passed
- `cargo build` — clean compile
- `npm run release` — built `.app` and `.dmg` successfully
- Launched `.app` bundle — all 4 dependencies detected (ffmpeg, yt-dlp, JS runtime, bgutil-ytdlp-pot-provider)
## Lessons Learned
- **Python version managers (pyenv) create shim binaries** that dispatch to a specific Python version. When a tool like yt-dlp is both Homebrew-installed and pyenv/pip-installed, PATH ordering determines which one runs — and they can have different plugin directories.
- **Simulating the `.app` environment** with `env -i HOME="$HOME" PATH="<minimal>" yt-dlp -v` was the key diagnostic step that instantly reproduced the issue (`Plugin directories: none` vs `Plugin directories: ~/.pyenv/...`).
- The `bump-version.sh` script works correctly for multi-file version bumps across `VERSION`, `package.json`, `Cargo.toml`, `tauri.conf.json`, and `Cargo.lock`.
## Follow-up Items
- The `npm pkg set` command in `bump-version.sh` produces a cosmetic JSON error with `--json` flag before falling back; could be cleaned up.

View File

@@ -0,0 +1,86 @@
# v0.1.2 Polish — Placeholders, Auto-Upgrade, Analysis Cache
**Date**: 2026-09-22 14:50 ET
**Duration**: ~20 minutes (brainstorm) + ~20 minutes (execution)
**Version**: 0.1.1 → 0.1.2
**Commits**: 2f81011..925a3fc (11 commits)
## Task Description
Three polish features for v0.1.2:
1. Fix misleading "Generating Waveform…" / "Generating Thumbnails…" placeholder text shown in the timeline before any video is loaded
2. Automatically use cached HQ video files instead of prompting user to "Upgrade" when the export file is already downloaded
3. Cache video analysis results (waveforms, keyframes, thumbnails, captions) to avoid reprocessing the same video on reload
## Changes Made
### Feature 1: Idle-State Placeholder Text
- **`src/lib/timeline/renderer.ts`**: Added `hasVideo` boolean parameter to `drawTimeline()` and `showLabel` to `drawPlaceholderLane()`. When no video is loaded, timeline lanes show empty backgrounds without misleading "Generating…" text.
- **`src/lib/components/Timeline.svelte`**: Passes `session.status === 'ready'` as the `hasVideo` argument.
### Feature 2: Auto-Use Cached HQ Video
- **`src/lib/stores/videoSession.svelte.ts`**: Restructured `beginDownload()` to check export (HQ) cache before preview cache. When HQ file exists on disk, it's used directly as the active video — no preview download, no upgrade toast. The upgrade toast is preserved for mid-session HQ completions (to avoid jarring video source swaps during playback).
### Feature 3: Analysis Cache System
**New Rust service** (`src-tauri/src/services/cache_manager.rs`):
- Cache stored at `~/Library/Application Support/gui-video-clipper/cache/`
- Videos keyed by SHA-256 URL hash (first 16 hex chars)
- JSON manifest per video with waveform tiers, keyframe positions, thumbnail spritesheets, caption paths
- Functions: `load_manifest`, `save_manifest`, `clear_cache_entry`, `clear_all_cache`, `get_cache_stats`
- 5 unit tests for URL hashing and path computation
**New IPC commands** (`src-tauri/src/commands/cache.rs`):
- `load_cached_analysis` / `save_analysis_to_cache` / `clear_video_cache` / `clear_all_cache` / `get_cache_stats` / `get_thumbnail_cache_dir` / `get_caption_cache_dir`
- Path resolution: manifest stores relative paths, IPC layer resolves to absolute for frontend
**Updated `extract_thumbnails`**: Added optional `output_dir` parameter so thumbnails can be directed to cache directory instead of random temp dir.
**TypeScript bindings** (`src/lib/bindings/cache.ts`): Full wrapper layer for all cache IPC commands.
**Frontend integration** (`src/lib/stores/videoSession.svelte.ts`):
- `triggerPostDownloadProcessing()` checks cache first — on hit, populates session state instantly and skips ffmpeg. On miss, extracts normally then saves to cache.
- Thumbnails directed to cache dir via `getThumbnailCacheDir`
- New `reprocessCurrentVideo()` export: clears cache entry and re-runs extraction
**Preferences panel**: Cache management section showing total size and entry count with "Clear All Cache" button.
**StatusBar**: Per-video "↻" reprocess button visible when processing is complete.
**Dependency added**: `sha2 = "0.10"` for URL hashing.
## Files Changed (15 files)
### New Files (3)
- `src-tauri/src/services/cache_manager.rs`
- `src-tauri/src/commands/cache.rs`
- `src/lib/bindings/cache.ts`
### Modified Files (12)
- `src/lib/timeline/renderer.ts`
- `src/lib/components/Timeline.svelte`
- `src/lib/stores/videoSession.svelte.ts`
- `src-tauri/src/services/mod.rs`
- `src-tauri/src/commands/mod.rs`
- `src-tauri/src/lib.rs`
- `src-tauri/Cargo.toml` + `Cargo.lock`
- `src-tauri/src/commands/media_analysis.rs`
- `src/lib/bindings/mediaAnalysis.ts`
- `src/lib/components/PreferencesPanel.svelte`
- `src/lib/components/StatusBar.svelte`
- `VERSION`, `package.json`, `tauri.conf.json`
## Test Results
- **Rust**: 56 tests pass, 0 warnings
- **Frontend**: svelte-check 0 errors/warnings, vitest 31/31 pass
## Follow-Up Items
- **HQ+toast edge case**: When video loaded with cached HQ file, `triggerPostDownloadProcessing` can still set `showUpgradeToast = true` even though playback is already HQ. Cosmetic — toast appears but does nothing harmful. Could add a guard `activeVideoPath !== exportFilePath` in a future pass.
- **`now_iso8601` naming**: Function in `cache_manager.rs` returns Unix epoch seconds, not ISO 8601. Cosmetic naming issue.
- **Caption cache dir**: `getCaptionCacheDir` is wired but not used yet — captions still go to temp dir. The manifest stores whatever path captions end up at. Could wire caption output to cache dir in a future pass for full persistence.
- **Project files**: Cache manifest structure is designed as forward-looking scaffold for future project file save/load. No UI implemented yet.
## Lessons Learned
- Checking export cache before preview cache in `beginDownload()` was a clean refactor that simplified the flow — early return avoids duplicate logic.
- Inlining waveform/keyframe data in JSON manifests works well for the data sizes involved (~2 MB worst case). No need for binary serialization.
- The `#![allow(dead_code)]` at module scope in `cache_manager.rs` was pragmatic but broader than ideal — individual annotations would be more precise. Resolved naturally once Task 5 wired up the commands.

View File

@@ -0,0 +1,57 @@
# Fix v0.1.2 Regressions
**Date:** 2026-09-22 15:21
**Task:** Fix three regressions introduced by the v0.1.2 caching work
## Changes Made
### Bug 1: Slow keyframe/thumbnail/caption generation (seconds → minutes)
**Root cause:** When the HQ export file was cached from a prior session, `beginDownload()` passed the HQ file to `triggerPostDownloadProcessing()`. All ffmpeg operations ran on the full-resolution file instead of the ~360p preview, causing 10-50x slowdown.
**Fix in `src/lib/stores/videoSession.svelte.ts`:** In the HQ early-return path, also check for cached preview and prefer it for ffmpeg analysis while still using HQ for playback:
```typescript
const previewForProcessing = await checkCachedDownload(session.title, 'preview').catch(() => null);
triggerPostDownloadProcessing(previewForProcessing || exportCached);
```
### Bug 2: Blank thumbnails
**Root cause:** Tauri's asset protocol scope in `tauri.conf.json` only allowed temp directories. The new thumbnail cache directory under `~/Library/Application Support/` was blocked, so `convertFileSrc()` requests were denied.
**Fix (3 files):**
1. `src-tauri/src/services/cache_manager.rs`: Added `OnceLock<PathBuf>` + `init()` function so `cache_base_dir()` uses the Tauri-resolved app data directory (matching `$APPDATA`).
2. `src-tauri/src/lib.rs`: Call `cache_manager::init(app.path().app_data_dir())` during setup.
3. `src-tauri/tauri.conf.json`: Added `"$APPDATA/**"` to the asset protocol scope.
**Side effect:** Existing caches under `~/Library/Application Support/gui-video-clipper/cache/` are orphaned. New location is `~/Library/Application Support/xyz.cottongin.gui-video-clipper/cache/`. One-time cost for a brand-new feature.
### Bug 3: False upgrade toast when already on HQ
**Root cause:** `triggerPostDownloadProcessing()` unconditionally set `showUpgradeToast = true` when `exportStatus === 'complete'`, even when `activeVideoPath` was already the export file.
**Fix in `src/lib/stores/videoSession.svelte.ts`:** Added guard `&& session.activeVideoPath !== session.exportFilePath` to both toast-setting locations (cache-hit and cache-miss blocks).
### Defensive fix: ffmpeg `-y` flag
**File:** `src-tauri/src/services/thumbnail_extractor.rs`
Added `-y` flag to the ffmpeg thumbnail extraction command to prevent hangs if re-extracting to a directory with existing frame files.
## Files Changed
- `src/lib/stores/videoSession.svelte.ts` — Fixes 1 and 3
- `src-tauri/src/services/cache_manager.rs` — Fix 2 (OnceLock for app data dir)
- `src-tauri/src/lib.rs` — Fix 2 (init cache manager in setup)
- `src-tauri/tauri.conf.json` — Fix 2 (add $APPDATA to scope)
- `src-tauri/src/services/thumbnail_extractor.rs` — Defensive `-y` flag
## Lessons Learned
1. When adding a caching layer that moves files from temp dirs to persistent dirs, always verify that security scopes (Tauri asset protocol, CSP, etc.) cover the new location.
2. When adding an "HQ early return" optimization, consider that downstream processing might still benefit from the smaller preview file — separate the "playback source" from the "analysis source."
3. Toast/notification conditions should always guard against the "already in target state" case, not just the "target state reached" case.
4. ffmpeg commands writing to potentially non-empty directories need `-y` to avoid interactive prompts that hang `.output()` calls.
## Follow-up Items
- Consider cleaning up orphaned caches under the old `gui-video-clipper` directory.

View File

@@ -0,0 +1,55 @@
# Fix Post-v0.1.2 Issues
**Date:** 2026-09-22 15:48
**Task:** Fix broken keyframe detection, missing cached captions, and About dialog version duplication
## Changes Made
### Issue 1: Keyframe buttons always greyed out
**Root cause:** ffprobe 8.x no longer populates the `flags` field. The parser in `keyframe_index.rs` expected 3-column CSV output (`media_type,pts_time,flags`) and checked `parts[2].contains('K')`. With ffprobe 8.0.1, the output was just timestamps (flags absent), so the parser's `parts.len() >= 3` check failed on every line, always returning `[]`.
Confirmed locally: `ffprobe -show_entries frame=pts_time,flags -of csv=p=0 -skip_frame nokey` produces:
```
0.000000,
1.000000
```
**Fix in `src-tauri/src/services/keyframe_index.rs`:**
- Changed ffprobe command to only request `frame=pts_time` (dropped `flags` since `-skip_frame nokey` already filters to keyframes)
- Rewrote `parse_ffprobe_output` to handle: (a) new format: plain timestamp per line with optional trailing comma, (b) legacy format: `section,pts_time[,flags]`
- Updated all tests — 5 tests covering new format, legacy with flags, legacy with section prefix, empty, and whitespace
### Issue 2: Captions not cached or loaded from cache
**Root cause:** In `save_analysis_to_cache` (cache.rs), caption paths were relativized via `strip_prefix(&cache_dir)`. But captions live outside the cache dir (e.g. `/tmp/video-clipper/subtitles/embedded.vtt`), so `strip_prefix` always failed and `relative_caption` was `None`. Captions were never saved to the manifest.
**Fix (2 files):**
1. `src-tauri/src/commands/cache.rs`: When `strip_prefix` fails, copy the caption file into `cache_dir/captions/` and store the relative path.
2. `src/lib/stores/videoSession.svelte.ts`: In the cache-hit path of `triggerPostDownloadProcessing`, if `captionFilePath` is null (old caches without captions), fall back to `loadCaptionsWithCache(filePath)` for re-extraction.
### Issue 3: About dialog shows "v0.1.2 (0.1.2)"
**Root cause:** The native macOS About dialog shows "Version {CFBundleShortVersionString} ({CFBundleVersion})". Tauri sets both from the same `version` field, producing "Version 0.1.2 (0.1.2)".
**Fix in `src-tauri/src/lib.rs`:** Created a custom macOS app menu with `AboutMetadataBuilder` that only sets `short_version` (no build number). Also provides standard Edit menu (undo/redo/cut/copy/paste/select-all). Version is read at compile time via `env!("CARGO_PKG_VERSION")`.
## Files Changed
- `src-tauri/src/services/keyframe_index.rs` — Fix 1: ffprobe command + parser + tests
- `src-tauri/src/commands/cache.rs` — Fix 2: copy captions into cache dir
- `src/lib/stores/videoSession.svelte.ts` — Fix 2: caption fallback on cache hit
- `src-tauri/src/lib.rs` — Fix 3: custom macOS menu
## Verification
- `cargo check`: 0 warnings, 0 errors
- `cargo test`: 58 passed, 0 failed
- `svelte-check`: 0 errors, 0 warnings
- `ReadLints`: no issues
## Lessons Learned
1. ffprobe output format can change across major versions. The `flags` field was silently dropped in ffprobe 8.x. Always test against the actual binary on the target system.
2. When caching file references, files outside the cache directory need to be copied in — `strip_prefix` silently returns `None` on failure via `.ok()`, making the bug invisible.
3. Native macOS About dialogs auto-format version info as "Version X (Build Y)". When both values are identical, it looks like a bug. Use `AboutMetadataBuilder` to control this.

View File

@@ -0,0 +1,42 @@
# Fix About Dialog & Reprocess Always Uses Low-Res
**Date**: 2026-09-22 16:07
**Task**: Fix native About dialog duplication/icon + force reprocessing to always use low-res preview file
## Issues Addressed
### 1. Native About Dialog Still Showing (Duplicate Version)
**Problem**: The macOS menu "About" item still opened the native About panel (which shows duplicate version strings "v0.1.2 (0.1.2)") instead of the custom Svelte `AboutDialog`.
**Fix**: Replaced the `AboutMetadataBuilder`-based `.about()` menu item with a custom `MenuItemBuilder` that emits a Tauri event (`open-about`). The frontend listens for this event via `listen('open-about', ...)` in `App.svelte` and toggles `showAboutDialog = true`.
**Files changed**:
- `src-tauri/src/lib.rs` — Replaced `AboutMetadataBuilder` usage with `MenuItemBuilder` + `PredefinedMenuItem` for hide/show/quit. Added `.on_menu_event()` handler to emit `open-about` event.
- `src/App.svelte` — Added `import { listen } from '@tauri-apps/api/event'` and a `$effect` that listens for `open-about` and opens the custom dialog.
### 2. Broken Icon in About Dialog
**Problem**: The `<img src="/favicon.svg">` tag failed to load in the Tauri webview, showing a broken image placeholder.
**Fix**: Inlined the SVG directly into the `AboutDialog.svelte` component, eliminating the external file reference. Added `rx="90"` to the background rect for rounded-corner macOS icon styling.
**Files changed**:
- `src/lib/components/AboutDialog.svelte` — Replaced `<img>` tag with inline `<svg>`.
### 3. Reprocessing Uses Full-Resolution File (Performance)
**Problem**: `reprocessCurrentVideo()` used `session.activeVideoPath` which points to the HQ export file after upgrade. All ffmpeg analysis (waveform, keyframes, thumbnails) on the full-res file takes minutes instead of seconds.
**Fix**: `reprocessCurrentVideo()` now explicitly prefers `session.previewFilePath` for processing. If that equals the export file (or is null), it falls back to checking the disk cache for a low-res preview via `checkCachedDownload(title, 'preview')`. Only uses `activeVideoPath` as a last resort.
**Files changed**:
- `src/lib/stores/videoSession.svelte.ts` — Rewrote `reprocessCurrentVideo()` to resolve the low-res preview path before triggering processing.
## Verification
- `cargo check` — 0 errors, 0 warnings
- `cargo test` — 58 tests passed
- `svelte-check` — 0 errors, 0 warnings
- `ReadLints` — No linter errors
## Lessons Learned
- Tauri's `.about(Some(metadata))` on `SubmenuBuilder` always creates the *native* macOS About panel — there is no way to redirect it to a custom webview dialog. To intercept "About", you must use a regular `MenuItemBuilder` with `.on_menu_event()`.
- Inline SVGs in Svelte components are more reliable than `<img src="...">` references in Tauri webviews, where path resolution can differ between dev and production builds.
- When a session tracks both a preview and export file path, any function that runs ffmpeg analysis must explicitly resolve which path to use — defaulting to `activeVideoPath` is a footgun since that gets upgraded to HQ.

View File

@@ -0,0 +1,56 @@
# v0.1.3 Polish/Tweaks Implementation Summary
**Date:** 2026-09-22 16:55 ET
**Version:** 0.1.2 → 0.1.3
## Task Description
Implemented three UI polish features for v0.1.3 of the GUI Video Clipper (Tauri + Svelte 5):
1. **Adjustable clip list layout** — clip list can appear below the timeline (default) or as a left sidebar next to the video preview, with a toolbar toggle button and persisted preference
2. **Timeline timestamp magnification** — increased base font from 10px to 12px, with a mouse-proximity magnification effect using cosine interpolation (~18px peak, 80px radius)
3. **Timeline right-click context menu** — custom context menu replacing the default web menu, with Mark In/Out, Go to Clip Start/End, and Delete Clip actions
## Changes Made
### Commits (oldest → newest)
- `0b8bec2` — chore: bump version to 0.1.3
- `b371ec6` — feat: adjustable clip list layout (bottom/left sidebar)
- `923c7b0` — fix: clip list width edge cases (falsy check, narrow-window clamp)
- `27c199e` — feat: larger timeline timestamps with mouse-proximity magnification
- `8c27177` — feat: timeline right-click context menu with mark/seek/delete actions
- `279e27a` — docs: user edits to v0.1.3 spec (version bump section)
### Files Modified
- `VERSION` — new file, version 0.1.3
- `package.json` — version bump
- `package-lock.json` — lockfile sync
- `src-tauri/tauri.conf.json` — version bump
- `src-tauri/Cargo.toml` — version bump
- `src-tauri/Cargo.lock` — lockfile sync
- `src/lib/stores/preferences.svelte.ts` — added `clipListPosition` and `clipListWidth` preferences
- `src/App.svelte` — dual layout modes (bottom/left), toggle button, sidebar resize
- `src/lib/components/ClipList.svelte` — added `position` prop for sidebar styling
- `src/lib/timeline/renderer.ts` — larger base font, magnification math in `drawTimeTicks()`
- `src/lib/components/Timeline.svelte` — mouseX tracking, context menu wiring
- `src/lib/components/TimelineContextMenu.svelte` — new context menu component
### Design & Plan Docs
- `docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md` — design spec
- `docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md` — implementation plan
## Process
Used subagent-driven development: 5 tasks dispatched as subagents with task reviews between each. Two reviewer findings on Task 2 (falsy width check, narrow-window clamp) were fixed before proceeding.
## Lessons Learned
- The layout restructure required careful conditional rendering in App.svelte — the `{#if}/{:else}` approach creates two separate DOM trees. This means TransportControls gets remounted on layout toggle, which should be smoke-tested.
- Context menu `$effect` for viewport clamping can flash position (0,0) for one frame before adjustment — initializing adjusted positions from the raw x/y props would eliminate this.
- The bump-version.sh script doesn't sync package-lock.json — needed manual `npm install --package-lock-only`.
## Follow-Up Items
- [ ] Manual smoke test with `npm run tauri dev` — layout toggle, sidebar resize, timestamp magnification feel, context menu flow
- [ ] Consider fixing first-frame context menu position flash
- [ ] Consider adding `onresize` clamp for sidebar width when window shrinks below saved width

View File

@@ -0,0 +1,44 @@
# Fix Resize + Waveform Alignment
**Date:** 2026-09-22 17:42
**Task:** Implement the "Fix Resize + Waveform Alignment" plan to fix horizontal resize regression and waveform/lane alignment issues.
## Changes Made
### 1. Resize Regression Fix — `src/App.svelte`
- **Root cause:** After the CSS-grid refactor, `handleResizeMove` used `splitAreaEl.clientHeight` as the upper bound. Since `.lower-section` only contained the resize handle (5px) + timeline pane, `totalHeight ≈ timelineHeight + 5`, so the clamp `Math.min(totalHeight - 20, ...)` immediately locked the height.
- **Fix:**
- Removed `splitAreaEl` state variable.
- Removed `bind:this={splitAreaEl}` from the `.lower-section` div.
- Simplified `handleResizeMove` to use a fixed max of 500 (matching the existing keyboard resize handler): `Math.max(40, Math.min(500, resizeStartHeight + delta))`.
### 2. Dynamic Lane Layout — `src/lib/timeline/renderer.ts`
- Removed fixed lane height constants (`THUMB_LANE_HEIGHT`, `WAVEFORM_LANE_HEIGHT`, `CONTROLS_LANE_HEIGHT`).
- Added `LaneLayout` interface and `computeLanes(totalHeight)` function that dynamically computes lane positions proportionally based on total canvas height.
- Controls lane: fixed 30px at bottom.
- Clip lane: fixed 30px above controls.
- Remaining space split 55/45 between thumbnails and waveform (with minimums of 20px and 15px).
- Updated `drawTimeline` to call `computeLanes(height)` and pass layout to all sub-draw functions.
- Updated `drawTimeTicks` to accept `LaneLayout` and use `lanes.controlsY` instead of `state.height - CONTROLS_LANE_HEIGHT`.
### 3. Clip Renderer Update — `src/lib/timeline/clipRenderer.ts`
- Removed hardcoded `CLIP_LANE_Y` and `CLIP_LANE_HEIGHT` constants.
- Updated `drawClips` and `hitTestClip` signatures to accept `LaneLayout` parameter.
- All clip positioning now uses `lanes.clipY` and `lanes.clipHeight`.
### 4. Timeline Component Update — `src/lib/components/Timeline.svelte`
- Imported `computeLanes` from renderer.
- Updated `hitTestClip` call to pass `computeLanes(timelineState.height)`.
## Verification
- `npm run check` (svelte-check): 0 errors, 0 warnings.
## Files Modified
- `src/App.svelte`
- `src/lib/timeline/renderer.ts`
- `src/lib/timeline/clipRenderer.ts`
- `src/lib/components/Timeline.svelte`
## Lessons Learned
- When using CSS grid layouts, elements that previously represented the "total space" may now only contain their own children. Relying on `clientHeight` of such elements for resize calculations breaks — use fixed bounds instead.
- Dynamic lane computation is preferable to fixed pixel constants because it adapts to any canvas height, preventing gaps or overlaps when the user resizes the timeline.

View File

@@ -0,0 +1,733 @@
# Build Cleanup & Release Packaging Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminate all build warnings/errors across frontend and backend, fix the failing test, and implement a release build script that produces a distributable `GUI Video Clipper.app`.
**Architecture:** Direct fixes to existing files — no new components or modules except `scripts/build-release.sh`. TypeScript config fixes, Svelte a11y attribute additions, Rust `#[allow]` annotations, one test assertion update, and a new shell script.
**Tech Stack:** Svelte 5, TypeScript, Vite/Vitest, Rust/Cargo, Tauri v2, Bash
## Global Constraints
- macOS is the only target platform
- All changes must result in zero warnings from `npm run check`, `cargo build`, and `npm test`
- Preserve existing runtime behavior — these are lint/warning fixes, not behavioral changes
- Follow existing code patterns and styles in each file
---
### Task 1: TypeScript Error Fixes
**Files:**
- Modify: `tsconfig.json`
- Modify: `vite.config.ts:1` (import line only)
- Test: `npm run check` (svelte-check)
**Interfaces:**
- Consumes: nothing
- Produces: Clean `npm run check` (0 errors for T1–T4; a11y warnings will still be present until Task 2)
- [ ] **Step 1: Install `@types/node`**
Run:
```bash
npm install -D @types/node
```
Expected: Package added to `devDependencies` in `package.json`.
- [ ] **Step 2: Add `"types": ["node"]` to `tsconfig.json`**
Edit `tsconfig.json` — add `"types": ["node"]` inside `compilerOptions`:
```json
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ESNext",
"isolatedModules": true,
"types": ["node"],
"paths": {
"$lib/*": ["./src/lib/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.svelte", "tests/**/*.ts", "vite.config.ts"]
}
```
- [ ] **Step 3: Change `defineConfig` import in `vite.config.ts`**
Change line 4 from:
```typescript
import { defineConfig } from 'vite';
```
to:
```typescript
import { defineConfig } from 'vitest/config';
```
This re-export extends `UserConfigExport` with vitest's `test` property.
- [ ] **Step 4: Verify TS errors are resolved**
Run:
```bash
npm run check 2>&1 | grep -E "^(Error|.*Error:)"
```
Expected: No lines matching "Error" — only a11y warnings remain.
- [ ] **Step 5: Commit**
```bash
git add tsconfig.json vite.config.ts package.json package-lock.json
git commit -m "fix: resolve TypeScript errors in vite.config.ts
- Install @types/node for node:path, node:process, node:url
- Import defineConfig from vitest/config for test config type
- Add types: [\"node\"] to tsconfig.json"
```
---
### Task 2: Svelte A11y Warning Fixes
**Files:**
- Modify: `src/lib/components/CaptionSettingsPanel.svelte:48-209` (label associations)
- Modify: `src/lib/components/ClipList.svelte:46` (tabindex + keydown)
- Modify: `src/lib/components/PreferencesPanel.svelte:60-61` (tabindex + keydown)
- Modify: `src/lib/components/ExportDialog.svelte:139-140` (tabindex + keydown)
- Modify: `src/lib/components/AboutDialog.svelte:15-16` (tabindex + keydown)
- Modify: `src/lib/components/VideoPlayer.svelte:187-197` (track element)
- Modify: `src/App.svelte:188-193` (resize handle keyboard support)
- Test: `npm run check` (svelte-check)
**Interfaces:**
- Consumes: Task 1 completed (TS errors resolved)
- Produces: Zero a11y warnings from `npm run check` and `npm run build`
- [ ] **Step 1: Fix CaptionSettingsPanel labels (9 warnings)**
The labels at lines 48, 61, 74, 90, 108, 120, 165, 195, and 209 are not associated with their controls. Fix each by adding `id` attributes to controls and `for` attributes to labels. Use unique IDs prefixed with `caption-`.
**Line 48 — Font label + select:** Change:
```svelte
<label>Font</label>
<select
value={settings.fontFamily}
```
to:
```svelte
<label for="caption-font">Font</label>
<select
id="caption-font"
value={settings.fontFamily}
```
**Line 61 — Font Size label + range input:** The label wraps the text but not the input. Wrap the input inside the label:
```svelte
<label>
Font Size
<span class="value">{settings.fontSize}px</span>
<input
type="range" min="12" max="36" step="1"
value={settings.fontSize}
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
/>
</label>
```
Remove the standalone `<input>` that was a sibling below the closing `</label>`.
**Line 74 — Text Color label:** Change:
```svelte
<label>Text Color</label>
<div class="row-controls">
<input
type="color" value={settings.textColor}
```
to:
```svelte
<label for="caption-text-color">Text Color</label>
<div class="row-controls">
<input
id="caption-text-color"
type="color" value={settings.textColor}
```
**Line 90 — Dimmed Text label:** This is a heading-style label for a radio group. Use `id`/`for` pointing to the first radio:
```svelte
<label for="caption-dimmed-auto">Dimmed Text</label>
<div class="radio-row">
<label>
<input id="caption-dimmed-auto" type="radio" name="dimmed-mode" value="auto"
```
**Line 108 — Dim Opacity label + range:** Wrap the input inside the label (same pattern as Font Size):
```svelte
<label>
Dim Opacity
<span class="value">{Math.round(settings.dimmedOpacity * 100)}%</span>
<input
type="range" min="10" max="90" step="5"
value={Math.round(settings.dimmedOpacity * 100)}
oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
/>
</label>
```
Remove the standalone `<input>` below.
**Line 120 — Dimmed Color label:** Change:
```svelte
<label>Dimmed Color</label>
<div class="row-controls">
<input
type="color" value={settings.dimmedColor}
```
to:
```svelte
<label for="caption-dimmed-color">Dimmed Color</label>
<div class="row-controls">
<input
id="caption-dimmed-color"
type="color" value={settings.dimmedColor}
```
**Line 165 — BG Opacity label + range:** Wrap input inside label (same as Font Size/Dim Opacity):
```svelte
<label>
BG Opacity
<span class="value">{Math.round(settings.backgroundOpacity * 100)}%</span>
<input
type="range" min="0" max="100" step="5"
value={Math.round(settings.backgroundOpacity * 100)}
oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
/>
</label>
```
Remove the standalone `<input>` below.
**Line 195 — Shadow Depth label + range:** Wrap input inside label:
```svelte
<label>
Shadow Depth
<span class="value">{settings.shadowDepth}px</span>
<input
type="range" min="1" max="5" step="1"
value={settings.shadowDepth}
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
/>
</label>
```
Remove the standalone `<input>` below.
**Line 209 — Position label:** Same as Dimmed Text — heading for radio group:
```svelte
<label for="caption-pos-bottom">Position</label>
<div class="radio-row">
<label>
<input id="caption-pos-bottom" type="radio" name="caption-position" value="bottom"
```
- [ ] **Step 2: Fix ClipList (2 warnings: tabindex + key handler)**
In `ClipList.svelte` line 46, the `<div class="clip-list" role="listbox">` needs `tabindex="0"` and a `onkeydown` handler. Change:
```svelte
<div class="clip-list" role="listbox" onclick={handleContainerClick}>
```
to:
```svelte
<div
class="clip-list"
role="listbox"
tabindex="0"
onclick={handleContainerClick}
onkeydown={(e) => {
if (e.key === 'Escape') {
selectClip(null);
}
}}
>
```
- [ ] **Step 3: Fix PreferencesPanel (2 warnings: tabindex + key handler)**
In `PreferencesPanel.svelte`, change lines 60-61:
```svelte
<div class="overlay" role="presentation" onclick={onClose}>
<div class="panel" role="dialog" onclick={(e) => e.stopPropagation()}>
```
to:
```svelte
<div class="overlay" role="presentation" onclick={onClose} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="panel" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()}>
```
- [ ] **Step 4: Fix ExportDialog (2 warnings: tabindex + key handler)**
In `ExportDialog.svelte`, change lines 139-140:
```svelte
<div class="overlay" role="presentation" onclick={onClose}>
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
```
to:
```svelte
<div class="overlay" role="presentation" onclick={onClose} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="dialog" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()}>
```
- [ ] **Step 5: Fix AboutDialog (2 warnings: tabindex + key handler)**
In `AboutDialog.svelte`, change lines 15-16:
```svelte
<div class="overlay" role="presentation" onclick={onClose}>
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
```
to:
```svelte
<div class="overlay" role="presentation" onclick={onClose} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
<div class="dialog" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()}>
```
- [ ] **Step 6: Fix VideoPlayer (1 warning: video without track)**
In `VideoPlayer.svelte`, add a `<track>` inside the `<video>` element after the `playsinline` attribute (around line 197):
```svelte
<video
bind:this={videoElement}
src={videoSrc}
ontimeupdate={handleTimeUpdate}
onplay={handlePlay}
onpause={handlePause}
onerror={handleError}
onloadeddata={handleLoadedData}
preload="metadata"
playsinline
>
<track kind="captions" />
</video>
```
Note: change the self-closing `></video>` to wrap the `<track>` element.
- [ ] **Step 7: Fix App.svelte resize handle (1 warning: non-interactive element with mouse handler)**
In `App.svelte`, update the resize handle div (lines 188-193) to add keyboard support:
```svelte
<div
class="resize-handle"
class:active={isResizing}
role="separator"
aria-orientation="horizontal"
aria-valuenow={timelineHeight}
aria-valuemin={40}
aria-valuemax={500}
tabindex="0"
onmousedown={handleResizeStart}
onkeydown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
timelineHeight = Math.min(500, timelineHeight + 10);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
timelineHeight = Math.max(40, timelineHeight - 10);
}
}}
></div>
```
- [ ] **Step 8: Verify all a11y warnings are resolved**
Run:
```bash
npm run check 2>&1
```
Expected: `svelte-check found 0 errors and 0 warnings`
Also verify the production build:
```bash
npm run build 2>&1 | grep -c "svelte"
```
Expected: No a11y warnings in the Vite build output (only the normal build success lines).
- [ ] **Step 9: Commit**
```bash
git add src/lib/components/CaptionSettingsPanel.svelte \
src/lib/components/ClipList.svelte \
src/lib/components/PreferencesPanel.svelte \
src/lib/components/ExportDialog.svelte \
src/lib/components/AboutDialog.svelte \
src/lib/components/VideoPlayer.svelte \
src/App.svelte
git commit -m "fix: resolve all Svelte a11y warnings
- Associate labels with controls in CaptionSettingsPanel (9 warnings)
- Add tabindex and keyboard handlers to dialog overlays (8 warnings)
- Add <track> to video element for caption accessibility
- Add keyboard support to resize handle separator"
```
---
### Task 3: Rust Dead Code Annotations
**Files:**
- Modify: `src-tauri/src/commands/export.rs:23`
- Modify: `src-tauri/src/commands/video.rs:23`
- Modify: `src-tauri/src/services/clip_exporter.rs:10,204,210`
- Modify: `src-tauri/src/services/video_resolver.rs:7`
- Test: `cargo build`
**Interfaces:**
- Consumes: nothing
- Produces: Zero warnings from `cargo build`
- [ ] **Step 1: Annotate `ExportEvent::Error` in `commands/export.rs`**
Add `#[allow(dead_code)]` on the `Error` variant (line 23):
```rust
Finished {
paths: Vec<String>,
},
#[allow(dead_code)] // Reserved for future error-channel reporting
Error {
message: String,
},
```
- [ ] **Step 2: Annotate `DownloadEvent::Error` in `commands/video.rs`**
Add `#[allow(dead_code)]` on the `Error` variant (line 23):
```rust
Finished { success: bool, path: String },
#[allow(dead_code)] // Reserved for future error-channel reporting
Error { message: String },
```
- [ ] **Step 3: Annotate unused functions in `services/clip_exporter.rs`**
Add `#[allow(dead_code)]` on each unused function:
Line 10:
```rust
/// Strip YouTube auto-generated VTT karaoke tags and positioning metadata
/// that confuse ffmpeg's VTT parser / mov_text conversion.
/// Returns the path to a cleaned temp VTT file.
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
pub fn sanitize_vtt_for_ffmpeg(caption_path: &str, temp_dir: &str) -> Result<String, String> {
```
Line 204:
```rust
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
fn opacity_to_ass_back_colour(opacity: f64) -> String {
```
Line 210:
```rust
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
fn caption_style_to_force_style(style: &CaptionStyle) -> String {
```
- [ ] **Step 4: Annotate `SubtitleFormat` in `services/video_resolver.rs`**
Add `#[allow(dead_code)]` on the struct (line 7):
```rust
#[derive(Debug, Deserialize)]
#[allow(dead_code)] // Fields used for serde deserialization
pub struct SubtitleFormat {
pub ext: Option<String>,
pub url: Option<String>,
}
```
- [ ] **Step 5: Verify zero Rust warnings**
Run:
```bash
cd src-tauri && cargo build 2>&1 | grep "warning:"
```
Expected: No output (zero warnings).
- [ ] **Step 6: Commit**
```bash
git add src-tauri/src/commands/export.rs \
src-tauri/src/commands/video.rs \
src-tauri/src/services/clip_exporter.rs \
src-tauri/src/services/video_resolver.rs
git commit -m "fix: suppress Rust dead code warnings with annotations
- ExportEvent::Error and DownloadEvent::Error reserved for future use
- clip_exporter functions scaffolded for caption burn-in pipeline
- SubtitleFormat fields required for serde deserialization"
```
---
### Task 4: Test Fix
**Files:**
- Modify: `tests/lib/stores/clips.test.ts:60-68`
- Test: `npm test`
**Interfaces:**
- Consumes: nothing (test-only change)
- Produces: 31/31 tests passing
- [ ] **Step 1: Update the failing test assertion**
In `tests/lib/stores/clips.test.ts`, change the test "updates selected clip start when I is pressed" (lines 60-68):
```typescript
it('updates selected clip start when I is pressed', () => {
addClip(10, 20);
const id = getClips()[0].id;
selectClip(id);
markInPoint(12);
expect(getClips()[0].startTime).toBe(12);
expect(getPendingInPoint()).toBeNull();
});
```
The change: `markInPoint(5)` → `markInPoint(12)` and `toBe(5)` → `toBe(12)`.
Time 12 is inside the clip (10–20) so `isTimeInsideClip` returns true and the edit path runs. Time 5 was outside the 0.5s tolerance (needed ≥ 9.5).
- [ ] **Step 2: Verify all tests pass**
Run:
```bash
npm test
```
Expected:
```
Test Files 5 passed (5)
Tests 31 passed (31)
```
- [ ] **Step 3: Commit**
```bash
git add tests/lib/stores/clips.test.ts
git commit -m "fix: correct clip in-point edit test for EDIT_TOLERANCE
markInPoint(5) is outside the clip (10-20) with 0.5s tolerance.
Use markInPoint(12) which is inside the clip and triggers the edit path."
```
---
### Task 5: Release Build Script & Packaging
**Files:**
- Create: `scripts/build-release.sh`
- Modify: `src-tauri/tauri.conf.json:38` (bundle targets)
- Modify: `.gitignore` (add `dist/`)
- Modify: `package.json` (add `release` script)
- Modify: `README.md` (add Release Build section)
- Test: `npm run release`
**Interfaces:**
- Consumes: Tasks 1–4 completed (clean builds)
- Produces: `dist/release/GUI Video Clipper.app` — a runnable, ad-hoc signed macOS app bundle
- [ ] **Step 1: Change bundle targets in `tauri.conf.json`**
In `src-tauri/tauri.conf.json`, change line 38 from:
```json
"targets": "all",
```
to:
```json
"targets": ["app", "dmg"],
```
- [ ] **Step 2: Add `dist/` to `.gitignore`**
Append to `.gitignore`:
```
dist/
```
- [ ] **Step 3: Add `release` script to `package.json`**
Add to the `"scripts"` section of `package.json`:
```json
"release": "bash scripts/build-release.sh"
```
Place it after the existing `"tauri"` script.
- [ ] **Step 4: Create `scripts/build-release.sh`**
```bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_FILE="$PROJECT_ROOT/VERSION"
# ── Read and validate version ──────────────────────────────────────
if [ ! -f "$VERSION_FILE" ]; then
echo "ERROR: VERSION file not found" >&2
exit 1
fi
VERSION=$(cat "$VERSION_FILE" | sed 's/^[vV]//' | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: '$VERSION' is not valid semver (expected X.Y.Z)" >&2
exit 1
fi
echo "=== Building GUI Video Clipper v$VERSION ==="
echo ""
# ── Install frontend dependencies ──────────────────────────────────
echo "→ Installing dependencies…"
cd "$PROJECT_ROOT"
npm install --silent
# ── Run tauri build ────────────────────────────────────────────────
echo "→ Building release (this may take a few minutes)…"
npx tauri build 2>&1 | tail -5
# ── Locate built artifacts ─────────────────────────────────────────
# Tauri outputs to src-tauri/target/release/bundle/
BUNDLE_DIR="$PROJECT_ROOT/src-tauri/target/release/bundle"
APP_SRC="$BUNDLE_DIR/macos/GUI Video Clipper.app"
DMG_GLOB="$BUNDLE_DIR/dmg/GUI Video Clipper_*.dmg"
if [ ! -d "$APP_SRC" ]; then
echo "ERROR: .app bundle not found at '$APP_SRC'" >&2
echo "Check the build output above for errors." >&2
exit 1
fi
# ── Ad-hoc code sign ──────────────────────────────────────────────
echo "→ Ad-hoc signing .app…"
codesign --force --deep -s - "$APP_SRC"
# ── Copy to dist/release/ ─────────────────────────────────────────
DIST_DIR="$PROJECT_ROOT/dist/release"
mkdir -p "$DIST_DIR"
echo "→ Copying to dist/release/…"
rm -rf "$DIST_DIR/GUI Video Clipper.app"
cp -R "$APP_SRC" "$DIST_DIR/"
# Copy DMG if it was built
DMG_FILE=$(ls $DMG_GLOB 2>/dev/null | head -1 || true)
if [ -n "$DMG_FILE" ]; then
cp "$DMG_FILE" "$DIST_DIR/"
echo " ✓ DMG: $(basename "$DMG_FILE")"
fi
# ── Summary ────────────────────────────────────────────────────────
APP_SIZE=$(du -sh "$DIST_DIR/GUI Video Clipper.app" | cut -f1)
echo ""
echo "=== Build Complete ==="
echo " Version: v$VERSION"
echo " App: dist/release/GUI Video Clipper.app ($APP_SIZE)"
if [ -n "$DMG_FILE" ]; then
DMG_SIZE=$(du -sh "$DIST_DIR/$(basename "$DMG_FILE")" | cut -f1)
echo " DMG: dist/release/$(basename "$DMG_FILE") ($DMG_SIZE)"
fi
echo ""
echo "To run: open \"dist/release/GUI Video Clipper.app\""
```
Make it executable:
```bash
chmod +x scripts/build-release.sh
```
- [ ] **Step 5: Update README with Release Build section**
Add a new section after "## Build & Run" in `README.md`:
```markdown
## Release Build
```bash
npm run release
```
This runs the full Tauri production build, ad-hoc code signs the `.app` bundle, and copies the output to `dist/release/`.
The resulting `GUI Video Clipper.app` can be launched directly or dragged to `/Applications`.
```
- [ ] **Step 6: Verify the release build**
Run:
```bash
npm run release
```
Expected:
- Script prints version, build progress, and summary
- `dist/release/GUI Video Clipper.app` exists
- App launches: `open "dist/release/GUI Video Clipper.app"`
- [ ] **Step 7: Commit**
```bash
git add scripts/build-release.sh \
src-tauri/tauri.conf.json \
.gitignore \
package.json \
README.md
git commit -m "feat: add release build script
- scripts/build-release.sh: builds, signs, and copies .app to dist/release/
- Bundle targets narrowed to app + dmg
- npm run release convenience script
- README documents the release build workflow"
```
---
## Final Verification
After all tasks, run the full check suite:
```bash
npm run check # 0 errors, 0 warnings
npm test # 31/31 pass
cd src-tauri && cargo build 2>&1 | grep "warning:" # no output
npm run release # produces dist/release/GUI Video Clipper.app
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
# Build Cleanup & Release Packaging
**Date:** 2026-09-22
**Status:** Approved
## Overview
Resolve all build warnings and errors across the frontend (svelte-check, Vite) and backend (cargo), fix the failing test, and implement a release build script that produces a distributable `GUI Video Clipper.app` bundle.
## Scope
Two workstreams:
1. **Warning/Error Cleanup** — zero warnings from `npm run check`, `cargo build`, and `npm test`
2. **Release Build Packaging** — `scripts/build-release.sh` that produces a signed `.app` in `dist/release/`
## 1. TypeScript Errors (4)
All in `vite.config.ts`:
| Error | Root Cause | Fix |
|-------|-----------|-----|
| Cannot find `node:path` | Missing `@types/node` | `npm install -D @types/node`, add `"types": ["node"]` to `tsconfig.json` |
| Cannot find `node:process` | Same | Same |
| Cannot find `node:url` | Same | Same |
| `test` not in `UserConfigExport` | `defineConfig` from `vite` doesn't know vitest's `test` field | Import `defineConfig` from `vitest/config` instead |
## 2. Svelte A11y Warnings (19)
### 2a. Labels not associated with controls (9 in CaptionSettingsPanel.svelte)
Labels are siblings of their controls rather than wrapping them. Fix by either:
- Wrapping the `<input>`/`<select>` inside the `<label>` element, or
- Adding `id` to the control and `for` to the label
Prefer the wrapping approach where layout allows.
### 2b. Dialog divs missing tabindex (4 files)
Add `tabindex="-1"` to `<div role="dialog">` in `PreferencesPanel`, `ExportDialog`, `AboutDialog`, and `<div role="listbox">` in `ClipList`. Makes them programmatically focusable without entering the tab order.
### 2c. Click handlers without key handlers (4 files)
Dialog overlay `onclick={onClose}` divs need corresponding `onkeydown` handlers. Add Escape key handling on the overlay. For `ClipList`'s listbox container, add an `onkeydown` handler mirroring the container click logic.
### 2d. `<video>` without `<track>` (1 in VideoPlayer.svelte)
Add an empty `<track kind="captions" default />` to the `<video>` element. Our custom caption overlay handles actual rendering.
### 2e. Resize handle (1 in App.svelte)
The resize handle `<div>` with `role="separator"` and `onmousedown` needs:
- `tabindex="0"` for keyboard focusability
- `onkeydown` handler for arrow key resizing
- `aria-valuenow`, `aria-valuemin`, `aria-valuemax` attributes
## 3. Rust Dead Code Warnings (6)
Add `#[allow(dead_code)]` with explanatory comments:
| Warning | Location | Annotation Rationale |
|---------|----------|---------------------|
| `ExportEvent::Error` unused | `commands/export.rs:23` | Reserved for future error-channel reporting |
| `DownloadEvent::Error` unused | `commands/video.rs:23` | Reserved for future error-channel reporting |
| `sanitize_vtt_for_ffmpeg` unused | `clip_exporter.rs:10` | Scaffolded for caption burn-in pipeline |
| `opacity_to_ass_back_colour` unused | `clip_exporter.rs:204` | Scaffolded for caption burn-in pipeline |
| `caption_style_to_force_style` unused | `clip_exporter.rs:210` | Scaffolded for caption burn-in pipeline |
| `SubtitleFormat.ext`/`.url` unread | `video_resolver.rs:8-9` | Fields required for serde deserialization |
## 4. Test Fix
**File:** `tests/lib/stores/clips.test.ts`
**Test:** "updates selected clip start when I is pressed"
The test calls `markInPoint(5)` on a clip spanning 10–20. With `EDIT_TOLERANCE` of 0.5s, time 5 is outside the clip boundary (needs ≥ 9.5). The test was written before the tolerance check existed.
**Fix:** Change `markInPoint(5)` to `markInPoint(12)` (inside the clip, within tolerance). Update the assertion to `expect(getClips()[0].startTime).toBe(12)`. This verifies the edit actually changes the start time (unlike `markInPoint(10)` which would pass trivially since start is already 10).
After fix: 31/31 tests pass.
## 5. Release Build Script
### 5a. `scripts/build-release.sh`
A shell script that:
1. Reads `VERSION` from the project root
2. Runs `npm install`
3. Runs `npx tauri build` (builds frontend + Rust + bundles .app and .dmg)
4. Ad-hoc code signs the `.app`: `codesign --force --deep -s - <path>`
5. Copies `.app` (and `.dmg` if built) to `dist/release/`
6. Prints summary: version, output path, bundle size
### 5b. Configuration changes
- `tauri.conf.json`: Change `"targets": "all"` to `"targets": ["app", "dmg"]`
- `.gitignore`: Add `dist/`
- `package.json`: Add `"release": "bash scripts/build-release.sh"`
### 5c. README update
Add a "Release Build" section documenting `npm run release` and its outputs.
## Verification
After all changes:
- `npm run check` exits 0 with 0 errors, 0 warnings
- `cargo build 2>&1 | grep warning` returns nothing
- `npm test` shows 31/31 pass
- `npm run release` produces `dist/release/GUI Video Clipper.app`
- The `.app` launches without the Gatekeeper right-click workaround

View File

@@ -0,0 +1,211 @@
# v0.1.2 Polish: Placeholders, Auto-Upgrade, and Analysis Cache
**Date**: 2026-09-22
**Version**: 0.1.2
**Status**: Design
## Overview
Three polish features for v0.1.2:
1. Fix misleading placeholder text shown before any video is loaded
2. Automatically use cached HQ video files instead of prompting to upgrade
3. Cache video analysis results (waveforms, keyframes, thumbnails, captions) to avoid reprocessing
## 1. Idle-State Placeholder Text
### Problem
When the app first loads with no video, the timeline shows "Generating Waveform..." and "Generating Thumbnails..." in the placeholder lanes. No work is happening — this is misleading.
### Solution
Make `drawTimeline` context-aware by accepting a `hasVideo` boolean (derived from `session.status === 'ready'`).
- **No video loaded** (`hasVideo === false`): Draw empty lane backgrounds with no text.
- **Video loaded, data pending** (`hasVideo === true`, data missing): Show "Generating Waveform..." / "Generating Thumbnails..." as today.
### Files Changed
- `src/lib/timeline/renderer.ts`: Add `hasVideo` parameter to `drawTimeline()`. Update `drawPlaceholderLane()` to accept a boolean controlling whether to show text. When `hasVideo` is false, render the background rectangle but skip the text label.
- `src/lib/components/Timeline.svelte`: Pass `session.status === 'ready'` as the `hasVideo` argument.
## 2. Auto-Use Cached HQ Video
### Problem
When a video's HQ (export) file is already cached on disk from a prior session, the app still downloads a low-res preview, then shows an "Upgrade" toast to switch to HQ. The user should get the HQ version immediately.
### Solution
At the start of `beginDownload()`, check the export cache first. If the HQ file exists:
- Set `activeVideoPath` to the HQ file immediately
- Set `exportFilePath`, `exportStatus = 'complete'`, `exportProgress = 1.0`
- Mark preview as complete too (skip the preview download entirely)
- Run `triggerPostDownloadProcessing()` against the HQ file
- No upgrade toast shown
When no cached HQ exists, the existing two-track download flow (preview first, background HQ) continues unchanged. The upgrade toast still appears when HQ completes mid-session — silently swapping the video source during active playback would be jarring.
### Behavior Matrix
| HQ cached? | Preview cached? | Behavior |
|---|---|---|
| Yes | (irrelevant) | Use HQ directly. No preview download. No toast. |
| No | Yes | Use cached preview. Start background HQ download. Toast on HQ completion. |
| No | No | Download preview, start background HQ. Toast on HQ completion. |
### Files Changed
- `src/lib/stores/videoSession.svelte.ts`: Restructure `beginDownload()` to check export cache before preview cache. Add early-return path that sets HQ as active video.
## 3. Analysis Cache System
### Problem
Every time a video is loaded, waveforms, keyframes, and thumbnails are re-extracted from scratch via ffmpeg. For a 5-minute video this takes 10-30 seconds. Re-opening the same video should be instant.
### Architecture
#### Cache Directory
```
~/Library/Application Support/gui-video-clipper/cache/
<url-hash>/
manifest.json
thumbnails/
frame_000001.jpg
frame_000002.jpg
...
captions/
en.vtt
```
The URL hash is the first 16 hex characters of the SHA-256 of the source URL. This provides sufficient collision resistance while keeping paths short.
#### Manifest Schema
```json
{
"version": 1,
"sourceUrl": "https://youtube.com/watch?v=abc123",
"title": "Example Video",
"duration": 312.5,
"fps": 30.0,
"createdAt": "2026-09-22T18:00:00Z",
"lastAccessedAt": "2026-09-22T18:05:00Z",
"waveformTiers": {
"tier0": [0.1, 0.5, ...],
"tier1": [0.1, 0.3, 0.5, ...],
"tier2": [0.05, 0.1, 0.15, ...]
},
"keyframePositions": [0.0, 2.5, 5.0, 7.5],
"thumbnailSpritesheets": [
{
"filePath": "thumbnails",
"startIndex": 0,
"count": 100,
"thumbWidth": 160,
"thumbHeight": 90,
"columns": 10,
"intervalSeconds": 1.0
}
],
"captionFilePath": "captions/en.vtt"
}
```
**Data sizing**: Waveform tier2 is at most 200K floats (~1.6 MB as JSON). Tier0 and tier1 add ~100 KB combined. Keyframes are a few hundred entries. This all fits comfortably inline in JSON. Thumbnails are JPEG files on disk, referenced by relative path.
All paths in the manifest are relative to the cache entry directory.
#### New Rust Service: `cache_manager.rs`
```rust
// Public API
pub fn cache_dir_for_url(url: &str) -> PathBuf
pub fn load_manifest(url: &str) -> Option<CacheManifest>
pub fn save_manifest(url: &str, manifest: &CacheManifest) -> Result<(), String>
pub fn clear_cache_entry(url: &str) -> Result<(), String>
pub fn clear_all_cache() -> Result<(), String>
pub fn get_cache_stats() -> Result<CacheStats, String>
pub fn thumbnail_cache_dir(url: &str) -> PathBuf
pub fn caption_cache_dir(url: &str) -> PathBuf
```
`CacheManifest` struct mirrors the JSON schema above. `CacheStats` contains `entry_count: usize` and `total_bytes: u64`.
#### New IPC Commands
Added to `src-tauri/src/commands/` (new file `cache.rs` or added to existing):
| Command | Signature | Purpose |
|---|---|---|
| `load_cached_analysis` | `(url: String) -> Option<CachedAnalysis>` | Load all cached data for a URL |
| `save_analysis_to_cache` | `(url, waveform_tiers, keyframe_positions, thumbnail_spritesheets, caption_file_path) -> ()` | Persist analysis results |
| `clear_video_cache` | `(url: String) -> ()` | Delete one video's cache |
| `clear_all_cache` | `() -> ()` | Delete the entire cache dir |
| `get_cache_stats` | `() -> CacheStats` | Get entry count and total size |
| `get_thumbnail_cache_dir` | `(url: String) -> String` | Get the cache directory path for thumbnail output |
| `get_caption_cache_dir` | `(url: String) -> String` | Get the cache directory path for caption output |
`CachedAnalysis` contains:
- `waveformTiers: WaveformTiers`
- `keyframePositions: Vec<f64>`
- `thumbnailSpritesheets: Vec<ThumbnailSpritesheet>` (with absolute paths resolved from the cache dir)
- `captionFilePath: Option<String>` (absolute path)
`save_analysis_to_cache` accepts the individual analysis fields rather than a wrapper struct, keeping the IPC boundary simple. The Rust side constructs the `CacheManifest` from these fields plus metadata (source URL, title, timestamps) looked up from the manifest or computed fresh.
#### Frontend Integration
**`videoSession.svelte.ts` — `triggerPostDownloadProcessing()`**:
1. Call `load_cached_analysis(session.url)` before running ffmpeg.
2. On cache hit: populate session state directly, jump to `processingStep = 'done'`. Skip all ffmpeg extraction.
3. On cache miss: run extraction as today. After all steps complete (waveform, keyframes, thumbnails, captions), call `save_analysis_to_cache(...)` to persist.
**New TypeScript bindings**: `src/lib/bindings/cache.ts` with wrapper functions for the new IPC commands.
#### Thumbnail Path Changes
Currently, `extract_thumbnails` (Rust command) generates a UUID-based temp dir for thumbnails. With caching:
- Add an optional `output_dir: Option<String>` parameter to the `extract_thumbnails` Tauri command. When provided, thumbnails are written there. When `None`, the existing UUID-based temp dir behavior is preserved as a fallback.
- The frontend calls `get_thumbnail_cache_dir(session.url)` to get the cache-managed output path, then passes it to `extract_thumbnails`.
- Similarly, `get_caption_cache_dir(session.url)` provides the path for caption file output.
### Cache Invalidation UX
**Preferences Panel** (`PreferencesPanel.svelte`):
- New "Cache" section at the bottom
- Display total cache size (e.g., "Cache: 450 MB across 12 videos")
- "Clear All Cache" button with confirmation
**Per-Video Reprocess** (in `StatusBar.svelte` or near the transport controls):
- A small "↻" (reprocess) button visible when a video is loaded and processing is complete (`processingStep === 'done'`)
- Calls `clear_video_cache(session.url)` then re-runs `triggerPostDownloadProcessing()` on the current video file
- This re-extracts analysis data only — it does not re-download the video
### Future: Project Files
The cache manifest structure is designed to be referenced by future project files. A project file would store:
- The source URL
- Clip regions (start/end times, labels, colors)
- Caption settings
- A reference to the cache entry (by URL hash)
This design means a project file doesn't duplicate analysis data — it points to the cache. If the cache is cleared, the project can re-derive everything from the source URL.
No project file save/load UI is implemented in this version. The manifest structure is the forward-looking scaffold.
### Dead Code Annotations
Any types or fields scaffolded for future project file support (e.g., fields in `CacheManifest` not yet read by the frontend) will be annotated with `#[allow(dead_code)]` and a `// Future: used by project file save/load` comment. This satisfies the requirement to handle dead code warnings proactively (item 3b).
## Constraints
- **Zero new warnings/errors**: All existing warnings and errors were recently resolved. Any new warnings introduced during implementation must be addressed immediately.
- **macOS-only paths**: `~/Library/Application Support/` is macOS-specific, which matches the project's macOS-only target.
- **Backward compatibility**: Existing temp-dir downloads (preview/export) continue to work as before. The cache is additive — it doesn't replace the download mechanism.

View File

@@ -0,0 +1,201 @@
# v0.1.3 Polish & Tweaks — Design Spec
**Date:** 2026-09-22
**Version:** 0.1.2 → 0.1.3
## Overview
Three UI polish features for v0.1.3: adjustable clip-list layout, improved timeline timestamp labels with a mouse-proximity magnification effect, and a custom right-click context menu on the timeline.
---
## Feature 1: Adjustable Layout (Clip List Position)
### Current State
The main content area is a vertical stack:
```
┌──────────────────────────────┐
│ Toolbar │
├──────────────────────────────┤
│ Video Preview │
├──────────────────────────────┤
│ Transport Controls │
├──────────────────────────────┤
│ Timeline / Thumbs / Waveform │
├──────────────────────────────┤
│ Clip List │
├──────────────────────────────┤
│ Status Bar │
└──────────────────────────────┘
```
### Proposed: Two Layout Modes
A new `clipListPosition` preference (`'bottom' | 'left'`) controls where the clip list appears. Default: `'bottom'` (current behavior, unchanged).
When set to `'left'`, the layout becomes:
```
┌──────────────────────────────────────────┐
│ Toolbar │
├──────────┬───────────────────────────────┤
│ Clip │ Video Preview │
│ List │ │
│ │ │
│ ├───────────────────────────────┤
│ │ Transport Controls │
├──────────┴───────────────────────────────┤
│ Timeline / Thumbs / Waveform (full) │
├──────────────────────────────────────────┤
│ Status Bar │
└──────────────────────────────────────────┘
```
Key details:
- The clip list shares horizontal space only with the video preview and transport controls. The timeline remains full-width underneath — no horizontal space is sacrificed for timeline real estate.
- A vertical drag-to-resize handle sits between the clip list and the video area, allowing the user to adjust the sidebar width. Default width: ~220px. Min: 150px. Max: 40% of window width.
- The existing vertical resize handle between timeline and clip list only appears in `'bottom'` mode. In `'left'` mode, the timeline height is controlled by the existing `timelineHeight` state (still resizable via a horizontal drag handle between the upper section and the timeline).
### Toggle Button
A button in the toolbar (alongside ⚙ and ℹ) toggles between `'bottom'` and `'left'` modes. The icon visually hints at the current layout — e.g. a stacked-rows icon for bottom mode, a sidebar icon for left mode. Clicking it switches immediately and persists the preference.
### Persistence
- Add `clipListPosition: 'bottom' | 'left'` to the `Preferences` interface in `preferences.svelte.ts`.
- Add `clipListWidth: number` (default 220) for the sidebar width in left mode.
- Both are saved/loaded via the existing Tauri store mechanism.
### Implementation Notes
- `App.svelte` is the only component that needs structural changes. `ClipList.svelte`, `VideoPlayer.svelte`, and `Timeline.svelte` remain unchanged — they just get placed in different containers.
- In `'left'` mode, the `.content` area becomes:
- A flex column with two children:
1. **Upper section** (flex row): `[ClipList sidebar | flex column of [VideoPlayer, TransportControls]]`
2. **Lower section** (same as before): timeline pane with resize handle
- The vertical resize handle between the upper and lower sections works the same as the existing one.
- In `'bottom'` mode: the current layout is preserved exactly.
- The `ClipList` component needs no changes; it already fills its container. The only adjustment is removing the `border-top` when in left mode (the sidebar should have a `border-right` instead), which can be handled via a CSS class or prop.
---
## Feature 2: Timeline Timestamp Labels
### Current State
Timestamp labels are rendered at `10px` font size in `drawTimeTicks()` in `renderer.ts`. They are small and hard to read.
### Proposed Changes
1. **Increase base font size** from `10px` to `12px`.
2. **Mouse-proximity magnification effect:** As the cursor moves along the timeline, labels near the cursor smoothly scale up. This creates an invisible "magnifying lens" effect for timestamps.
### Magnification Behavior
- **Radius:** ~80px from cursor (in canvas pixel space).
- **Peak size:** ~18px directly under the cursor.
- **Falloff:** Smooth cosine interpolation from peak to base size. A label 80px away renders at base size; a label directly under the cursor renders at peak size.
- **Vertical alignment:** Labels stay bottom-aligned as they grow — the text baseline remains constant, so larger labels grow upward. This prevents layout jitter.
- **Cursor leaves timeline:** When the mouse leaves the canvas (`onmouseleave`), `mouseX` is set to `null` and all labels render at the base size. No magnification in this state.
### Implementation Notes
- `Timeline.svelte` already handles `onmousemove`. Add tracking of the mouse X coordinate (a simple `let mouseX = $state<number | null>(null)`), updated on `mousemove` and cleared on `mouseleave`.
- Pass `mouseX` into `drawTimeline()` → `drawTimeTicks()`.
- `drawTimeTicks()` computes the per-label font size based on distance from `mouseX`:
```
distance = abs(labelX - mouseX)
if distance > MAGNIFY_RADIUS: scale = 0
else: scale = (1 + cos(PI * distance / MAGNIFY_RADIUS)) / 2
fontSize = BASE_FONT_SIZE + BONUS_FONT_SIZE * scale
```
- Trigger a redraw whenever `mouseX` changes. Since `requestDraw()` already coalesces via `requestAnimationFrame`, this adds no extra frame overhead — it simply piggybacks on the next scheduled frame.
---
## Feature 3: Timeline Right-Click Context Menu
### Current State
Right-clicking the timeline shows the default web/Tauri context menu (Reload, Inspect Element), which is not useful.
### Proposed: Custom Context Menu
A styled context menu appears on right-click anywhere on the timeline canvas. Right-clicking also moves the playhead to the clicked position.
### Menu Items
| # | Label | Behavior | Availability |
|---|-------|----------|-------------|
| 1 | **Mark In** | Sets in-point at the right-clicked time (calls `markInPoint`) | Always |
| 2 | **Mark Out** | Sets out-point at the right-clicked time (calls `markOutPoint`) | Always |
| — | *(separator)* | | |
| 3 | **Go to Clip Start** | Seeks playhead to `selectedClip.startTime` | Disabled if no clip selected |
| 4 | **Go to Clip End** | Seeks playhead to `selectedClip.endTime` | Disabled if no clip selected |
| — | *(separator)* | | |
| 5 | **Delete Clip** | Removes the selected clip | Hidden if no clip selected |
### Behavior
- **Right-click on canvas:** `contextmenu` event is intercepted, default is prevented. The playhead moves to the clicked time position. The context menu appears at the cursor's screen coordinates.
- **Selecting an item:** Executes the action and closes the menu.
- **Closing without action:** Click anywhere outside the menu, press Escape, or scroll — the menu closes.
- **Positioning:** The menu is rendered as a positioned `<div>` appended at the `App.svelte` level (or uses a portal pattern) so it isn't clipped by any `overflow: hidden` containers. If the menu would extend beyond the window edge, it flips direction (e.g. opens leftward or upward).
### Component: `TimelineContextMenu.svelte`
Props:
- `x: number` — screen X position
- `y: number` — screen Y position
- `time: number` — the timeline time at the right-click position
- `selectedClipId: string | null` — to determine which items are enabled/hidden
- `onAction: (action: ContextMenuAction) => void` — callback for menu item selection
- `onClose: () => void` — callback to close the menu
Actions (discriminated union):
```typescript
type ContextMenuAction =
| { type: 'mark-in' }
| { type: 'mark-out' }
| { type: 'go-to-clip-start' }
| { type: 'go-to-clip-end' }
| { type: 'delete-clip' };
```
### Styling
- Dark theme matching the app's existing style (dark background, light text, hover highlights).
- Subtle border/shadow to float above the timeline.
- Disabled items: dimmed text, no hover effect, not clickable.
- Separators: thin horizontal lines using `var(--border)`.
### Implementation Notes
- `Timeline.svelte` adds an `oncontextmenu` handler on the canvas element.
- The handler computes the time from the click X coordinate (using existing `computeClickTime`), seeks the playhead, and emits an event / sets state to show the context menu.
- The context menu component is rendered conditionally in `Timeline.svelte` (or `App.svelte` if clipping is an issue).
- Keyboard shortcut hints can optionally be shown in the menu items (e.g. "Mark In I") for discoverability.
---
## Version Bump
Execute `scripts/bump-version.sh` after incrementing `VERSION`.
Confirm all locations updated from `0.1.2` → `0.1.3`:
- `package.json` — `version` field
- `src-tauri/tauri.conf.json` — `version` field
- `src-tauri/Cargo.toml` — `version` field
- `grep`/`find` for remaining `0.1.2` references, fix any, and add to `scripts/bump-version.sh`
---
## Scope Boundaries
- No changes to the backend (Rust). All three features are purely frontend.
- No changes to clip store logic — existing `markInPoint`, `markOutPoint`, `removeClip`, `selectClip` functions are reused as-is.
- No new dependencies.
- The context menu does NOT appear on the minimap — only on the main timeline canvas.

22
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "gui-video-clipper",
"version": "0.1.0",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gui-video-clipper",
"version": "0.1.0",
"version": "0.1.3",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
@@ -19,6 +19,7 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.2.8",
"@types/node": "^26.6.2",
"jsdom": "^26.1.0",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",
@@ -928,6 +929,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
@@ -2122,6 +2133,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "gui-video-clipper",
"version": "0.1.0",
"version": "0.1.3",
"description": "macOS GUI video clipper (Tauri + Svelte)",
"type": "module",
"scripts": {
@@ -9,7 +9,8 @@
"preview": "vite preview",
"test": "vitest run",
"check": "svelte-check --tsconfig ./tsconfig.json",
"tauri": "tauri"
"tauri": "tauri",
"release": "bash scripts/build-release.sh"
},
"license": "MIT",
"dependencies": {
@@ -23,6 +24,7 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.2.8",
"@types/node": "^26.6.2",
"jsdom": "^26.1.0",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",

82
scripts/build-release.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION_FILE="$PROJECT_ROOT/VERSION"
# ── Read and validate version ──────────────────────────────────────
if [ ! -f "$VERSION_FILE" ]; then
echo "ERROR: VERSION file not found" >&2
exit 1
fi
VERSION=$(cat "$VERSION_FILE" | sed 's/^[vV]//' | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: '$VERSION' is not valid semver (expected X.Y.Z)" >&2
exit 1
fi
echo "=== Building GUI Video Clipper v$VERSION ==="
echo ""
# ── Install frontend dependencies ──────────────────────────────────
echo "→ Installing dependencies…"
cd "$PROJECT_ROOT"
npm install --silent
# ── Run tauri build ────────────────────────────────────────────────
echo "→ Building release (this may take a few minutes)…"
BUILD_LOG=$(mktemp)
npx tauri build 2>&1 | tee "$BUILD_LOG" | tail -10
# ── Locate built artifacts ─────────────────────────────────────────
# Parse .app path from tauri build output (handles custom CARGO_TARGET_DIR)
APP_SRC=$(grep '\.app$' "$BUILD_LOG" | head -1 | sed 's/^[[:space:]]*//')
DMG_SRC=$(grep '\.dmg$' "$BUILD_LOG" | head -1 | sed 's/^[[:space:]]*//')
rm -f "$BUILD_LOG"
# Fall back to standard location if parsing failed
if [ -z "$APP_SRC" ] || [ ! -d "$APP_SRC" ]; then
BUNDLE_DIR="$PROJECT_ROOT/src-tauri/target/release/bundle"
APP_SRC="$BUNDLE_DIR/macos/GUI Video Clipper.app"
fi
if [ ! -d "$APP_SRC" ]; then
echo "ERROR: .app bundle not found" >&2
echo "Check the build output above for errors." >&2
exit 1
fi
# ── Ad-hoc code sign ──────────────────────────────────────────────
echo "→ Ad-hoc signing .app…"
codesign --force --deep -s - "$APP_SRC"
# ── Copy to dist/release/ ─────────────────────────────────────────
DIST_DIR="$PROJECT_ROOT/dist/release"
mkdir -p "$DIST_DIR"
echo "→ Copying to dist/release/…"
rm -rf "$DIST_DIR/GUI Video Clipper.app"
cp -R "$APP_SRC" "$DIST_DIR/"
# Copy DMG if it was built
DMG_FILE="$DMG_SRC"
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
cp "$DMG_FILE" "$DIST_DIR/"
echo " ✓ DMG: $(basename "$DMG_FILE")"
fi
# ── Summary ────────────────────────────────────────────────────────
APP_SIZE=$(du -sh "$DIST_DIR/GUI Video Clipper.app" | cut -f1)
echo ""
echo "=== Build Complete ==="
echo " Version: v$VERSION"
echo " App: dist/release/GUI Video Clipper.app ($APP_SIZE)"
if [ -n "$DMG_FILE" ] && [ -f "$DIST_DIR/$(basename "$DMG_FILE")" ]; then
DMG_SIZE=$(du -sh "$DIST_DIR/$(basename "$DMG_FILE")" | cut -f1)
echo " DMG: dist/release/$(basename "$DMG_FILE") ($DMG_SIZE)"
fi
echo ""
echo "To run: open \"dist/release/GUI Video Clipper.app\""

3
src-tauri/Cargo.lock generated
View File

@@ -1281,12 +1281,13 @@ dependencies = [
[[package]]
name = "gui-video-clipper"
version = "0.1.0"
version = "0.1.3"
dependencies = [
"axum",
"dirs",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",

View File

@@ -1,6 +1,6 @@
[package]
name = "gui-video-clipper"
version = "0.1.0"
version = "0.1.3"
description = "A macOS GUI app for clipping online videos"
authors = ["cottongin"]
edition = "2021"
@@ -32,6 +32,7 @@ tempfile = "3.27.0"
dirs = "6.0.0"
axum = "0.8"
tower-http = { version = "0.6", features = ["fs", "cors"] }
sha2 = "0.10"
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration

View File

@@ -0,0 +1,166 @@
use crate::models::{ThumbnailSpritesheet, WaveformTiers};
use crate::services::cache_manager::{self, CacheManifest, CacheStats};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedAnalysis {
pub waveform_tiers: Option<WaveformTiers>,
pub keyframe_positions: Vec<f64>,
pub thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
pub caption_file_path: Option<String>,
}
#[tauri::command]
pub async fn load_cached_analysis(url: String) -> Option<CachedAnalysis> {
let url_clone = url.clone();
let manifest = tokio::task::spawn_blocking(move || cache_manager::load_manifest(&url_clone))
.await
.ok()
.flatten()?;
let cache_dir = cache_manager::cache_dir_for_url(&url);
// Resolve relative paths in thumbnail spritesheets to absolute paths
let spritesheets: Vec<ThumbnailSpritesheet> = manifest
.thumbnail_spritesheets
.into_iter()
.map(|mut sheet| {
let abs_path = cache_dir.join(&sheet.file_path);
sheet.file_path = abs_path.to_string_lossy().to_string();
sheet
})
.collect();
// Verify thumbnail files actually exist on disk
let thumbs_valid = if spritesheets.is_empty() {
false
} else {
let first_dir = std::path::Path::new(&spritesheets[0].file_path);
first_dir.is_dir()
};
// Resolve caption path to absolute
let caption_path = manifest
.caption_file_path
.map(|rel| cache_dir.join(&rel).to_string_lossy().to_string())
.filter(|p| std::path::Path::new(p).is_file());
Some(CachedAnalysis {
waveform_tiers: manifest.waveform_tiers,
keyframe_positions: manifest.keyframe_positions,
thumbnail_spritesheets: if thumbs_valid {
spritesheets
} else {
vec![]
},
caption_file_path: caption_path,
})
}
#[tauri::command]
pub async fn save_analysis_to_cache(
url: String,
title: String,
duration: f64,
fps: f64,
waveform_tiers: Option<WaveformTiers>,
keyframe_positions: Vec<f64>,
thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
caption_file_path: Option<String>,
) -> Result<(), String> {
let cache_dir = cache_manager::cache_dir_for_url(&url);
// Convert absolute spritesheet paths to relative
let relative_sheets: Vec<ThumbnailSpritesheet> = thumbnail_spritesheets
.into_iter()
.map(|mut sheet| {
if let Ok(rel) = std::path::Path::new(&sheet.file_path).strip_prefix(&cache_dir) {
sheet.file_path = rel.to_string_lossy().to_string();
}
sheet
})
.collect();
// Convert absolute caption path to relative (copy into cache if needed)
let relative_caption = caption_file_path.and_then(|abs| {
let abs_path = std::path::Path::new(&abs);
// Already inside cache dir
if let Ok(rel) = abs_path.strip_prefix(&cache_dir) {
return Some(rel.to_string_lossy().to_string());
}
// Outside cache dir — copy it in
if abs_path.is_file() {
let captions_dir = cache_dir.join("captions");
std::fs::create_dir_all(&captions_dir).ok();
if let Some(filename) = abs_path.file_name() {
let dest = captions_dir.join(filename);
if std::fs::copy(abs_path, &dest).is_ok() {
return Some(format!("captions/{}", filename.to_string_lossy()));
}
}
}
None
});
let now = format!(
"{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
);
let manifest = CacheManifest {
version: 1,
source_url: url.clone(),
title,
duration,
fps,
created_at: now.clone(),
last_accessed_at: now,
waveform_tiers,
keyframe_positions,
thumbnail_spritesheets: relative_sheets,
caption_file_path: relative_caption,
};
tokio::task::spawn_blocking(move || cache_manager::save_manifest(&url, &manifest))
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub async fn clear_video_cache(url: String) -> Result<(), String> {
tokio::task::spawn_blocking(move || cache_manager::clear_cache_entry(&url))
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub async fn clear_all_cache() -> Result<(), String> {
tokio::task::spawn_blocking(cache_manager::clear_all_cache)
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub async fn get_cache_stats() -> Result<CacheStats, String> {
tokio::task::spawn_blocking(cache_manager::get_cache_stats)
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub fn get_thumbnail_cache_dir(url: String) -> String {
let dir = cache_manager::thumbnail_cache_dir(&url);
std::fs::create_dir_all(&dir).ok();
dir.to_string_lossy().to_string()
}
#[tauri::command]
pub fn get_caption_cache_dir(url: String) -> String {
let dir = cache_manager::caption_cache_dir(&url);
std::fs::create_dir_all(&dir).ok();
dir.to_string_lossy().to_string()
}

View File

@@ -20,6 +20,7 @@ pub enum ExportEvent {
Finished {
paths: Vec<String>,
},
#[allow(dead_code)] // Reserved for future error-channel reporting
Error {
message: String,
},

View File

@@ -51,15 +51,19 @@ pub async fn extract_waveform_tiers(
pub async fn extract_thumbnails(
file_path: String,
duration: f64,
output_dir: Option<String>,
) -> Result<Vec<ThumbnailSpritesheet>, String> {
eprintln!(
"[video-clipper:thumbnails] starting for '{}' (duration={})",
file_path, duration
);
let result = tokio::task::spawn_blocking(move || {
let temp_dir = std::env::temp_dir()
.join("video-clipper-thumbs")
.join(uuid::Uuid::new_v4().to_string());
let temp_dir = match output_dir {
Some(dir) => std::path::PathBuf::from(dir),
None => std::env::temp_dir()
.join("video-clipper-thumbs")
.join(uuid::Uuid::new_v4().to_string()),
};
let temp_dir_str = temp_dir.to_string_lossy().to_string();
thumbnail_extractor::extract_thumbnails(&file_path, duration, &temp_dir_str)
})

View File

@@ -1,3 +1,4 @@
pub mod cache;
pub mod dependencies;
pub mod export;
pub mod media_analysis;

View File

@@ -20,6 +20,7 @@ pub enum DownloadEvent {
Progress { percent: f64 },
FilePath { path: String },
Finished { success: bool, path: String },
#[allow(dead_code)] // Reserved for future error-channel reporting
Error { message: String },
}

View File

@@ -2,11 +2,90 @@ mod commands;
mod models;
mod services;
use commands::{dependencies, export, media_analysis, video};
use tauri::Manager;
use std::collections::HashSet;
use std::path::PathBuf;
use commands::{cache, dependencies, export, media_analysis, video};
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder};
use tauri::{Emitter, Manager};
struct MediaServerPort(u16);
/// Prepend well-known macOS tool directories to PATH so that Homebrew-installed
/// binaries (ffmpeg, yt-dlp, etc.) are discoverable when the app is launched as
/// a standalone .app bundle (which inherits only a minimal system PATH).
///
/// Also sets PYENV_ROOT when pyenv is present so that pyenv shims can resolve
/// to the correct Python (and its pip-installed yt-dlp plugins).
fn fix_path_env() {
let home = std::env::var("HOME").unwrap_or_default();
let mut extra_dirs: Vec<PathBuf> = Vec::new();
if !home.is_empty() {
let home_path = PathBuf::from(&home);
// pyenv shims — must come before /opt/homebrew/bin so the pyenv-managed
// yt-dlp (which knows about pip-installed plugins) wins over Homebrew's.
let pyenv_root = home_path.join(".pyenv");
if pyenv_root.is_dir() {
extra_dirs.push(pyenv_root.join("shims"));
if std::env::var("PYENV_ROOT").is_err() {
std::env::set_var("PYENV_ROOT", &pyenv_root);
}
}
// nvm — node/deno installed via nvm live under a versioned directory
let nvm_dir = home_path.join(".nvm/versions/node");
if nvm_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
// Pick the lexicographically last (highest) version
let mut versions: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path().join("bin"))
.filter(|p| p.is_dir())
.collect();
versions.sort();
if let Some(latest) = versions.pop() {
extra_dirs.push(latest);
}
}
}
// User-local installs
for rel in [".local/bin", ".cargo/bin", ".deno/bin"] {
extra_dirs.push(home_path.join(rel));
}
}
// System-wide Homebrew / MacPorts paths
for dir in [
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
] {
extra_dirs.push(PathBuf::from(dir));
}
// Filter to directories that actually exist
let extra_dirs: Vec<PathBuf> = extra_dirs.into_iter().filter(|p| p.is_dir()).collect();
let current = std::env::var("PATH").unwrap_or_default();
let current_dirs: HashSet<&str> = current.split(':').collect();
let mut new_path = String::new();
for dir in &extra_dirs {
let s = dir.to_string_lossy();
if !current_dirs.contains(s.as_ref()) {
new_path.push_str(&s);
new_path.push(':');
}
}
new_path.push_str(&current);
std::env::set_var("PATH", &new_path);
}
#[tauri::command]
fn get_media_server_port(state: tauri::State<'_, MediaServerPort>) -> u16 {
state.0
@@ -20,14 +99,60 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_process::init())
.setup(|app| {
fix_path_env();
// Initialize cache manager with Tauri-resolved app data directory
let app_data_dir = app.path().app_data_dir()
.expect("Failed to resolve app data dir");
std::fs::create_dir_all(&app_data_dir).ok();
services::cache_manager::init(app_data_dir);
// Bind synchronously (no async needed), then spawn on Tauri's runtime
let (port, listener) = services::media_server::bind_media_server()
.expect("Failed to bind media server");
eprintln!("[video-clipper] media server bound to port {port}");
app.manage(MediaServerPort(port));
services::media_server::spawn_media_server(listener);
// Custom macOS menu — open our Svelte About dialog instead of native
let about_item = MenuItemBuilder::new("About GUI Video Clipper")
.id("about")
.build(app)?;
let app_menu = SubmenuBuilder::new(app, "GUI Video Clipper")
.item(&about_item)
.separator()
.item(&PredefinedMenuItem::hide(app, None)?)
.item(&PredefinedMenuItem::hide_others(app, None)?)
.item(&PredefinedMenuItem::show_all(app, None)?)
.separator()
.quit()
.build()?;
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()?;
let menu = MenuBuilder::new(app)
.item(&app_menu)
.item(&edit_menu)
.build()?;
app.set_menu(menu)?;
Ok(())
})
.on_menu_event(|app, event| {
if event.id().as_ref() == "about" {
let _ = app.emit("open-about", ());
}
})
.invoke_handler(tauri::generate_handler![
dependencies::check_dependencies,
dependencies::install_dependency,
@@ -43,6 +168,13 @@ pub fn run() {
media_analysis::check_embedded_subtitles,
media_analysis::list_system_fonts,
export::export_clips,
cache::load_cached_analysis,
cache::save_analysis_to_cache,
cache::clear_video_cache,
cache::clear_all_cache,
cache::get_cache_stats,
cache::get_thumbnail_cache_dir,
cache::get_caption_cache_dir,
get_media_server_port,
])
.run(tauri::generate_context!())

View File

@@ -0,0 +1,221 @@
// Future: used by project file save/load and cache Tauri commands.
#![allow(dead_code)]
use crate::models::{ThumbnailSpritesheet, WaveformTiers};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::OnceLock;
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Initialize the cache manager with the Tauri-resolved app data directory.
/// Must be called once during app setup before any cache operations.
pub fn init(app_data_dir: PathBuf) {
APP_DATA_DIR.set(app_data_dir).ok();
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheManifest {
pub version: u32,
pub source_url: String,
pub title: String,
pub duration: f64,
pub fps: f64,
pub created_at: String,
pub last_accessed_at: String,
pub waveform_tiers: Option<WaveformTiers>,
pub keyframe_positions: Vec<f64>,
pub thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
pub caption_file_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheStats {
pub entry_count: usize,
pub total_bytes: u64,
}
/// First 16 hex characters of the SHA-256 hash of the URL.
pub fn url_hash(url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let result = hasher.finalize();
result[..8]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
}
pub fn cache_base_dir() -> PathBuf {
APP_DATA_DIR
.get()
.cloned()
.unwrap_or_else(|| {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("gui-video-clipper")
})
.join("cache")
}
pub fn cache_dir_for_url(url: &str) -> PathBuf {
cache_base_dir().join(url_hash(url))
}
pub fn thumbnail_cache_dir(url: &str) -> PathBuf {
cache_dir_for_url(url).join("thumbnails")
}
pub fn caption_cache_dir(url: &str) -> PathBuf {
cache_dir_for_url(url).join("captions")
}
fn manifest_path(url: &str) -> PathBuf {
cache_dir_for_url(url).join("manifest.json")
}
fn now_iso8601() -> String {
// Use a simple UTC timestamp without pulling in chrono
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
// Format as ISO 8601 — good enough for cache timestamps
format!("{secs}")
}
pub fn load_manifest(url: &str) -> Option<CacheManifest> {
let path = manifest_path(url);
if !path.is_file() {
return None;
}
let data = std::fs::read_to_string(&path).ok()?;
let mut manifest: CacheManifest = serde_json::from_str(&data).ok()?;
// Update last-accessed timestamp
manifest.last_accessed_at = now_iso8601();
if let Ok(json) = serde_json::to_string_pretty(&manifest) {
let _ = std::fs::write(&path, json);
}
Some(manifest)
}
pub fn save_manifest(url: &str, manifest: &CacheManifest) -> Result<(), String> {
let dir = cache_dir_for_url(url);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create cache dir: {e}"))?;
let path = dir.join("manifest.json");
let json = serde_json::to_string_pretty(manifest)
.map_err(|e| format!("Failed to serialize manifest: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("Failed to write manifest: {e}"))?;
eprintln!("[video-clipper:cache] saved manifest to '{}'", path.display());
Ok(())
}
pub fn clear_cache_entry(url: &str) -> Result<(), String> {
let dir = cache_dir_for_url(url);
if dir.is_dir() {
std::fs::remove_dir_all(&dir)
.map_err(|e| format!("Failed to remove cache entry: {e}"))?;
eprintln!("[video-clipper:cache] cleared entry at '{}'", dir.display());
}
Ok(())
}
pub fn clear_all_cache() -> Result<(), String> {
let base = cache_base_dir();
if base.is_dir() {
std::fs::remove_dir_all(&base)
.map_err(|e| format!("Failed to remove cache dir: {e}"))?;
eprintln!("[video-clipper:cache] cleared all cache at '{}'", base.display());
}
Ok(())
}
pub fn get_cache_stats() -> Result<CacheStats, String> {
let base = cache_base_dir();
if !base.is_dir() {
return Ok(CacheStats {
entry_count: 0,
total_bytes: 0,
});
}
let mut entry_count = 0usize;
let mut total_bytes = 0u64;
let entries = std::fs::read_dir(&base)
.map_err(|e| format!("Failed to read cache dir: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
entry_count += 1;
total_bytes += dir_size(&path);
}
}
Ok(CacheStats {
entry_count,
total_bytes,
})
}
fn dir_size(path: &std::path::Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() {
total += std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
} else if p.is_dir() {
total += dir_size(&p);
}
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_hash_length() {
let hash = url_hash("https://youtube.com/watch?v=abc123");
assert_eq!(hash.len(), 16);
}
#[test]
fn test_url_hash_deterministic() {
let url = "https://youtube.com/watch?v=abc123";
assert_eq!(url_hash(url), url_hash(url));
}
#[test]
fn test_url_hash_different_urls() {
let a = url_hash("https://youtube.com/watch?v=abc");
let b = url_hash("https://youtube.com/watch?v=xyz");
assert_ne!(a, b);
}
#[test]
fn test_cache_dir_for_url_contains_hash() {
let url = "https://youtube.com/watch?v=test";
let dir = cache_dir_for_url(url);
let hash = url_hash(url);
assert!(dir.to_string_lossy().contains(&hash));
}
#[test]
fn test_thumbnail_cache_dir_is_subdir() {
let url = "https://example.com/video";
let base = cache_dir_for_url(url);
let thumbs = thumbnail_cache_dir(url);
assert!(thumbs.starts_with(&base));
assert!(thumbs.to_string_lossy().ends_with("thumbnails"));
}
}

View File

@@ -7,6 +7,7 @@ use std::process::{Command, Stdio};
/// Strip YouTube auto-generated VTT karaoke tags and positioning metadata
/// that confuse ffmpeg's VTT parser / mov_text conversion.
/// Returns the path to a cleaned temp VTT file.
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
pub fn sanitize_vtt_for_ffmpeg(caption_path: &str, temp_dir: &str) -> Result<String, String> {
let content = std::fs::read_to_string(caption_path)
.map_err(|e| format!("Failed to read VTT file '{}': {e}", caption_path))?;
@@ -201,12 +202,14 @@ fn hex_to_ass_color(hex: &str) -> String {
/// Convert a background opacity (0.0=transparent, 1.0=opaque) to ASS BackColour.
/// ASS alpha: 00=opaque, FF=transparent (inverted from CSS).
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
fn opacity_to_ass_back_colour(opacity: f64) -> String {
let alpha = ((1.0 - opacity.clamp(0.0, 1.0)) * 255.0).round() as u8;
format!("&H{:02X}000000", alpha)
}
/// Build an ASS force_style string from user caption settings.
#[allow(dead_code)] // Scaffolded for caption burn-in pipeline
fn caption_style_to_force_style(style: &CaptionStyle) -> String {
let font_size = (style.font_size as f64 * 2.0).round() as u32;
let primary_colour = hex_to_ass_color(&style.text_color);

View File

@@ -54,17 +54,11 @@ pub fn check_js_runtime() -> Option<String> {
check_tool_exists("deno").or_else(|| check_tool_exists("node"))
}
pub fn check_pot_plugin(cookie_source: &CookieSource) -> bool {
let mut command = Command::new("yt-dlp");
command
.arg("-v")
.arg("--simulate")
.arg("--remote-components")
.arg("ejs:github")
.args(cookie_source.to_ytdlp_args())
.arg("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
let output = command.output();
pub fn check_pot_plugin(_cookie_source: &CookieSource) -> bool {
// Use `yt-dlp -v` (no URL) which lists loaded plugins without network access.
// The old approach ran a full YouTube simulation (~9s, network-dependent) which
// failed silently in the .app bundle context.
let output = Command::new("yt-dlp").arg("-v").output();
match output {
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);

View File

@@ -5,11 +5,18 @@ pub fn parse_ffprobe_output(output: &str) -> Vec<f64> {
output
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split(',').collect();
// Format: media_type,pts_time,flags
// We want lines where flags contain 'K' (keyframe)
if parts.len() >= 3 && parts[2].contains('K') {
parts[1].parse::<f64>().ok()
let trimmed = line.trim().trim_end_matches(',');
if trimmed.is_empty() {
return None;
}
// New format (ffprobe 8.x): just a timestamp per line
if let Ok(val) = trimmed.parse::<f64>() {
return Some(val);
}
// Legacy format: section,pts_time,flags or section,pts_time
let parts: Vec<&str> = trimmed.split(',').collect();
if parts.len() >= 2 {
parts[1].trim().parse::<f64>().ok()
} else {
None
}
@@ -23,7 +30,7 @@ pub fn extract_keyframes(file_path: &str) -> Result<Vec<f64>, String> {
"-select_streams",
"v",
"-show_entries",
"frame=pts_time,flags",
"frame=pts_time",
"-of",
"csv=p=0",
"-skip_frame",
@@ -49,8 +56,8 @@ mod tests {
use super::*;
#[test]
fn test_parse_ffprobe_keyframes() {
let output = "video,0.000000,K__\nvideo,0.500000,___\nvideo,1.000000,K__\nvideo,1.500000,___\nvideo,2.000000,K__\n";
fn test_parse_new_format_plain_timestamps() {
let output = "0.000000,\n1.000000\n2.000000,\n";
let result = parse_ffprobe_output(output);
assert_eq!(result.len(), 3);
assert!((result[0] - 0.0).abs() < 0.001);
@@ -59,13 +66,31 @@ mod tests {
}
#[test]
fn test_parse_ffprobe_empty() {
fn test_parse_legacy_format_with_flags() {
let output = "video,0.000000,K__\nvideo,0.500000,___\nvideo,1.000000,K__\nvideo,1.500000,___\nvideo,2.000000,K__\n";
let result = parse_ffprobe_output(output);
assert_eq!(result.len(), 5);
assert!((result[0] - 0.0).abs() < 0.001);
assert!((result[2] - 1.0).abs() < 0.001);
assert!((result[4] - 2.0).abs() < 0.001);
}
#[test]
fn test_parse_legacy_format_with_section_prefix() {
let output = "frame,0.000000,\nframe,1.000000\n";
let result = parse_ffprobe_output(output);
assert_eq!(result.len(), 2);
assert!((result[0] - 0.0).abs() < 0.001);
assert!((result[1] - 1.0).abs() < 0.001);
}
#[test]
fn test_parse_empty() {
assert!(parse_ffprobe_output("").is_empty());
}
#[test]
fn test_parse_ffprobe_no_keyframes() {
let output = "video,0.500000,___\nvideo,1.500000,___\n";
assert!(parse_ffprobe_output(output).is_empty());
fn test_parse_whitespace_only() {
assert!(parse_ffprobe_output(" \n \n").is_empty());
}
}

View File

@@ -1,3 +1,4 @@
pub mod cache_manager;
pub mod clip_exporter;
pub mod dependency_manager;
pub mod download_manager;

View File

@@ -30,6 +30,7 @@ pub fn extract_thumbnails(
let frames_pattern = format!("{output_dir}/frame_%06d.jpg");
let status = Command::new(ffmpeg_bin())
.args([
"-y",
"-i",
file_path,
"-vf",

View File

@@ -4,6 +4,7 @@ use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Deserialize)]
#[allow(dead_code)] // Fields used for serde deserialization
pub struct SubtitleFormat {
pub ext: Option<String>,
pub url: Option<String>,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GUI Video Clipper",
"version": "0.1.0",
"version": "0.1.3",
"identifier": "xyz.cottongin.gui-video-clipper",
"build": {
"beforeDevCommand": "npm run dev",
@@ -13,8 +13,8 @@
"windows": [
{
"title": "GUI Video Clipper",
"width": 800,
"height": 600
"width": 1100,
"height": 750
}
],
"security": {
@@ -27,14 +27,18 @@
"/tmp/**",
"/private/tmp/**",
"/private/var/**",
"/var/**"
"/var/**",
"$APPDATA/**"
]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"targets": [
"app",
"dmg"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View File

@@ -10,10 +10,11 @@
import ExportDialog from '$lib/components/ExportDialog.svelte';
import AboutDialog from '$lib/components/AboutDialog.svelte';
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
import { loadPreferences } from '$lib/stores/preferences.svelte';
import { loadPreferences, preferences, setClipListPosition, setClipListWidth } from '$lib/stores/preferences.svelte';
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
import { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
import { session } from '$lib/stores/videoSession.svelte';
import { listen } from '@tauri-apps/api/event';
import type { TransportKeyAction } from '$lib/transport/playback';
let showSetupWizard = $state(true);
@@ -29,12 +30,26 @@
let isResizing = $state(false);
let resizeStartY = $state(0);
let resizeStartHeight = $state(0);
let splitAreaEl = $state<HTMLDivElement | null>(null);
let isSidebarResizing = $state(false);
let sidebarResizeStartX = $state(0);
let sidebarResizeStartWidth = $state(0);
let windowWidth = $state(window.innerWidth);
let isLeft = $derived(preferences.clipListPosition === 'left');
$effect(() => {
loadPreferences();
});
// Listen for native macOS menu "About" action
$effect(() => {
const unlisten = listen('open-about', () => {
showAboutDialog = true;
});
return () => { unlisten.then((fn) => fn()); };
});
$effect(() => {
if (session.status === 'resolving') {
shuttleRate = 1;
@@ -54,10 +69,9 @@
}
function handleResizeMove(e: MouseEvent) {
if (!isResizing || !splitAreaEl) return;
if (!isResizing) return;
const delta = e.clientY - resizeStartY;
const totalHeight = splitAreaEl.clientHeight;
const newHeight = Math.max(40, Math.min(totalHeight - 20, resizeStartHeight + delta));
const newHeight = Math.max(40, Math.min(500, resizeStartHeight + delta));
timelineHeight = newHeight;
}
@@ -65,6 +79,34 @@
isResizing = false;
}
function toggleClipListPosition() {
const next = preferences.clipListPosition === 'bottom' ? 'left' : 'bottom';
setClipListPosition(next);
}
function handleSidebarResizeStart(e: MouseEvent) {
e.preventDefault();
isSidebarResizing = true;
sidebarResizeStartX = e.clientX;
sidebarResizeStartWidth = preferences.clipListWidth;
}
function handleSidebarResizeMove(e: MouseEvent) {
if (!isSidebarResizing) return;
const delta = e.clientX - sidebarResizeStartX;
const maxWidth = Math.round(windowWidth * 0.4);
const minWidth = Math.min(150, maxWidth);
const newWidth = Math.max(minWidth, Math.min(maxWidth, sidebarResizeStartWidth + delta));
preferences.clipListWidth = newWidth;
}
function handleSidebarResizeEnd() {
if (isSidebarResizing) {
isSidebarResizing = false;
setClipListWidth(preferences.clipListWidth);
}
}
function handleGlobalKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
@@ -147,8 +189,17 @@
<svelte:window
onkeydown={handleGlobalKeydown}
onmousemove={handleResizeMove}
onmouseup={handleResizeEnd}
onmousemove={(e) => {
handleResizeMove(e);
handleSidebarResizeMove(e);
}}
onmouseup={() => {
handleResizeEnd();
handleSidebarResizeEnd();
}}
onresize={() => {
windowWidth = window.innerWidth;
}}
/>
{#if showSetupWizard}
@@ -174,28 +225,91 @@
<div class="app-shell">
<header class="toolbar">
<UrlInput />
<button
class="layout-btn"
onclick={toggleClipListPosition}
title={preferences.clipListPosition === 'bottom'
? 'Move clip list to sidebar'
: 'Move clip list to bottom'}
>
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
</button>
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
</header>
<main class="content">
<VideoPlayer />
<TransportControls bind:this={transportControls} />
<div class="timeline-clip-area" bind:this={splitAreaEl}>
<div class="timeline-pane" style="height: {timelineHeight}px">
<Timeline />
</div>
<main class="content" class:layout-left={isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
<!-- Sidebar clip list: visible only in left mode -->
<div class="sidebar-area" class:hidden={!isLeft}>
<ClipList onExport={() => (showExportDialog = true)} position="left" />
</div>
<div
class="sidebar-handle"
class:hidden={!isLeft}
class:active={isSidebarResizing}
role="slider"
aria-orientation="vertical"
aria-label="Resize clip list sidebar"
aria-valuenow={preferences.clipListWidth}
aria-valuemin={150}
aria-valuemax={Math.round(windowWidth * 0.4)}
tabindex="0"
onmousedown={handleSidebarResizeStart}
onkeydown={(e) => {
const maxWidth = Math.round(windowWidth * 0.4);
const minWidth = Math.min(150, maxWidth);
if (e.key === 'ArrowRight') {
e.preventDefault();
setClipListWidth(
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
setClipListWidth(
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
);
}
}}
></div>
<!-- Video + Transport: always rendered, never destroyed -->
<div class="video-area">
<VideoPlayer />
<TransportControls bind:this={transportControls} />
</div>
<!-- Timeline section: always full width -->
<div class="lower-section">
<div
class="resize-handle"
class:active={isResizing}
role="separator"
role="slider"
aria-orientation="horizontal"
aria-label="Resize timeline"
aria-valuenow={timelineHeight}
aria-valuemin={40}
aria-valuemax={500}
tabindex="0"
onmousedown={handleResizeStart}
onkeydown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
timelineHeight = Math.min(500, timelineHeight + 10);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
timelineHeight = Math.max(40, timelineHeight - 10);
}
}}
></div>
<div class="cliplist-pane">
<ClipList onExport={() => (showExportDialog = true)} />
<div class="timeline-pane" style="height: {timelineHeight}px">
<Timeline />
</div>
</div>
<!-- Bottom clip list: visible only in bottom mode -->
<div class="bottom-cliplist" class:hidden={isLeft}>
<ClipList onExport={() => (showExportDialog = true)} />
</div>
</main>
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
@@ -217,20 +331,78 @@
gap: 8px;
}
/* --- Grid layout: bottom mode (default) --- */
.content {
flex: 1;
display: flex;
flex-direction: column;
display: grid;
grid-template-areas:
"video"
"lower"
"bottom-clips";
grid-template-columns: 1fr;
grid-template-rows: 1fr auto auto;
overflow: hidden;
}
.timeline-clip-area {
.video-area {
grid-area: video;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.lower-section {
grid-area: lower;
display: flex;
flex-direction: column;
flex-shrink: 0;
min-height: 80px;
}
.bottom-cliplist {
grid-area: bottom-clips;
min-height: 20px;
overflow: hidden;
}
/* Sidebar elements hidden in bottom mode */
.sidebar-area {
grid-area: sidebar;
overflow-y: auto;
overflow-x: hidden;
border-right: 1px solid var(--border);
background: var(--bg-secondary);
}
.sidebar-handle {
grid-area: handle;
width: 5px;
background: var(--border);
cursor: ew-resize;
flex-shrink: 0;
transition: background 0.1s;
}
.sidebar-handle:hover,
.sidebar-handle.active {
background: var(--accent);
}
.hidden {
display: none !important;
}
/* --- Grid layout: left mode --- */
.content.layout-left {
grid-template-areas:
"sidebar handle video"
"lower lower lower"
"bottom-clips bottom-clips bottom-clips";
grid-template-columns: var(--sidebar-width, 220px) 5px 1fr;
grid-template-rows: 1fr auto 0;
}
.timeline-pane {
flex-shrink: 0;
min-height: 40px;
@@ -250,10 +422,9 @@
background: var(--accent, #89b4fa);
}
.cliplist-pane {
flex: 1;
min-height: 20px;
overflow: hidden;
.layout-btn {
font-size: 16px;
padding: 4px 8px;
}
.about-btn {

60
src/lib/bindings/cache.ts Normal file
View File

@@ -0,0 +1,60 @@
import { invoke } from '@tauri-apps/api/core';
import type { WaveformTiers, ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
export interface CachedAnalysis {
waveformTiers: WaveformTiers | null;
keyframePositions: number[];
thumbnailSpritesheets: ThumbnailSpritesheet[];
captionFilePath: string | null;
}
export interface CacheStats {
entryCount: number;
totalBytes: number;
}
export async function loadCachedAnalysis(url: string): Promise<CachedAnalysis | null> {
return invoke<CachedAnalysis | null>('load_cached_analysis', { url });
}
export async function saveAnalysisToCache(
url: string,
title: string,
duration: number,
fps: number,
waveformTiers: WaveformTiers | null,
keyframePositions: number[],
thumbnailSpritesheets: ThumbnailSpritesheet[],
captionFilePath: string | null
): Promise<void> {
return invoke<void>('save_analysis_to_cache', {
url,
title,
duration,
fps,
waveformTiers,
keyframePositions,
thumbnailSpritesheets,
captionFilePath,
});
}
export async function clearVideoCache(url: string): Promise<void> {
return invoke<void>('clear_video_cache', { url });
}
export async function clearAllCache(): Promise<void> {
return invoke<void>('clear_all_cache');
}
export async function getCacheStats(): Promise<CacheStats> {
return invoke<CacheStats>('get_cache_stats');
}
export async function getThumbnailCacheDir(url: string): Promise<string> {
return invoke<string>('get_thumbnail_cache_dir', { url });
}
export async function getCaptionCacheDir(url: string): Promise<string> {
return invoke<string>('get_caption_cache_dir', { url });
}

View File

@@ -41,11 +41,13 @@ export async function extractWaveformTiers(
export async function extractThumbnails(
filePath: string,
duration: number
duration: number,
outputDir?: string
): Promise<ThumbnailSpritesheet[]> {
return invoke<ThumbnailSpritesheet[]>('extract_thumbnails', {
filePath,
duration,
outputDir: outputDir ?? null,
});
}

View File

@@ -12,9 +12,62 @@
});
</script>
<div class="overlay" role="presentation" onclick={onClose}>
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
<img class="app-icon" src="/favicon.svg" alt="GUI Video Clipper icon" />
<div
class="overlay"
role="presentation"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
if (e.key !== 'Escape') e.preventDefault();
onClose();
}
}}
>
<div
class="dialog"
role="dialog"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
<stop offset="0%" stop-color="#fb923c"/>
<stop offset="100%" stop-color="#ea580c"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="90" fill="#18181b"/>
<g transform="translate(-3, 3)">
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<g transform="translate(3, -3)">
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
</g>
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
<g transform="rotate(20, 256, 256)">
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
</g>
</svg>
<h2>GUI Video Clipper</h2>
{#if version}
<span class="version">v{version}</span>

View File

@@ -45,8 +45,9 @@
<div class="scroll-area">
<!-- Font Family -->
<div class="control">
<label>Font</label>
<label for="caption-font">Font</label>
<select
id="caption-font"
value={settings.fontFamily}
onchange={(e) => update({ fontFamily: (e.target as HTMLSelectElement).value })}
>
@@ -61,19 +62,20 @@
<label>
Font Size
<span class="value">{settings.fontSize}px</span>
<input
type="range" min="12" max="36" step="1"
value={settings.fontSize}
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
/>
</label>
<input
type="range" min="12" max="36" step="1"
value={settings.fontSize}
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
/>
</div>
<!-- Text Color + Bold -->
<div class="control row">
<label>Text Color</label>
<label for="caption-text-color">Text Color</label>
<div class="row-controls">
<input
id="caption-text-color"
type="color" value={settings.textColor}
oninput={(e) => update({ textColor: (e.target as HTMLInputElement).value })}
/>
@@ -87,10 +89,10 @@
<!-- Dimmed Text Color -->
<div class="control">
<label>Dimmed Text</label>
<label for="caption-dimmed-auto">Dimmed Text</label>
<div class="radio-row">
<label>
<input type="radio" name="dimmed-mode" value="auto"
<input id="caption-dimmed-auto" type="radio" name="dimmed-mode" value="auto"
checked={settings.dimmedColorMode === 'auto'}
onchange={() => update({ dimmedColorMode: 'auto' })} />
Auto
@@ -108,18 +110,19 @@
<label>
Dim Opacity
<span class="value">{Math.round(settings.dimmedOpacity * 100)}%</span>
<input
type="range" min="10" max="90" step="5"
value={Math.round(settings.dimmedOpacity * 100)}
oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
/>
</label>
<input
type="range" min="10" max="90" step="5"
value={Math.round(settings.dimmedOpacity * 100)}
oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
/>
</div>
{:else}
<div class="control row">
<label>Dimmed Color</label>
<label for="caption-dimmed-color">Dimmed Color</label>
<div class="row-controls">
<input
id="caption-dimmed-color"
type="color" value={settings.dimmedColor}
oninput={(e) => update({ dimmedColor: (e.target as HTMLInputElement).value })}
/>
@@ -165,12 +168,12 @@
<label>
BG Opacity
<span class="value">{Math.round(settings.backgroundOpacity * 100)}%</span>
<input
type="range" min="0" max="100" step="5"
value={Math.round(settings.backgroundOpacity * 100)}
oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
/>
</label>
<input
type="range" min="0" max="100" step="5"
value={Math.round(settings.backgroundOpacity * 100)}
oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
/>
</div>
{/if}
@@ -195,21 +198,21 @@
<label>
Shadow Depth
<span class="value">{settings.shadowDepth}px</span>
<input
type="range" min="1" max="5" step="1"
value={settings.shadowDepth}
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
/>
</label>
<input
type="range" min="1" max="5" step="1"
value={settings.shadowDepth}
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
/>
</div>
{/if}
<!-- Position -->
<div class="control">
<label>Position</label>
<label for="caption-pos-bottom">Position</label>
<div class="radio-row">
<label>
<input type="radio" name="caption-position" value="bottom"
<input id="caption-pos-bottom" type="radio" name="caption-position" value="bottom"
checked={settings.position === 'bottom'}
onchange={() => update({ position: 'bottom' })} />
Bottom

View File

@@ -4,22 +4,26 @@
selectClip,
removeClip,
updateClip,
type Clip,
} from '$lib/stores/clips.svelte';
import { formatTime, parseTime } from '$lib/utils/time';
import { session } from '$lib/stores/videoSession.svelte';
import { seekTo } from '$lib/transport/playback';
let { onExport }: { onExport?: () => void } = $props();
let {
onExport,
position = 'bottom',
}: { onExport?: () => void; position?: 'bottom' | 'left' } = $props();
let clips = $derived(clipStore.clips);
let selectedId = $derived(clipStore.selectedClipId);
let isSidebar = $derived(position === 'left');
function handleSelect(id: string) {
selectClip(id);
const clip = clips.find((c) => c.id === id);
if (clip) {
session.currentTime = clip.startTime;
const videoEl = document.querySelector('video');
if (videoEl) videoEl.currentTime = clip.startTime;
seekTo(clip.startTime);
}
}
@@ -36,14 +40,38 @@
function handleContainerClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Only deselect if the click landed on the container itself, not a child
if (target.classList.contains('clip-list')) {
selectClip(null);
}
}
function handlePlayClip(clip: Clip) {
seekTo(clip.startTime);
const videoEl = document.querySelector('video');
if (videoEl?.paused) videoEl.play();
}
function handleGoToStart(clip: Clip) {
seekTo(clip.startTime);
}
function handleGoToEnd(clip: Clip) {
seekTo(clip.endTime);
}
</script>
<div class="clip-list" role="listbox" onclick={handleContainerClick}>
<div
class="clip-list"
class:sidebar={isSidebar}
role="listbox"
tabindex="0"
onclick={handleContainerClick}
onkeydown={(e) => {
if (e.key === 'Escape') {
selectClip(null);
}
}}
>
{#if clips.length === 0}
<div class="empty">No clips yet — press I to mark in-point, O to mark out-point</div>
{:else}
@@ -54,6 +82,7 @@
<div
class="clip-row"
class:selected={clip.id === selectedId}
class:sidebar={isSidebar}
role="option"
aria-selected={clip.id === selectedId}
tabindex="0"
@@ -65,42 +94,124 @@
}
}}
>
<span class="color-swatch" style="background: {clip.color}"></span>
<input
class="label-input"
type="text"
value={clip.label}
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<input
class="time-input"
type="text"
value={formatTime(clip.startTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<span class="arrow">→</span>
<input
class="time-input"
type="text"
value={formatTime(clip.endTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<button
class="delete-btn"
onclick={(e) => {
e.stopPropagation();
removeClip(clip.id);
}}
title="Delete clip"
>✕</button>
{#if isSidebar}
<!-- Sidebar (vertical card) layout -->
<div class="clip-card">
<div class="card-row">
<span class="color-swatch" style="background: {clip.color}"></span>
<input
class="label-input sidebar-label"
type="text"
value={clip.label}
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
</div>
<div class="card-row times">
<input
class="time-input"
type="text"
value={formatTime(clip.startTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<span class="arrow">→</span>
<input
class="time-input"
type="text"
value={formatTime(clip.endTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
</div>
{#if clip.id === selectedId}
<div class="clip-actions">
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
title="Play clip"
>▶</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
title="Go to start"
>|◀</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
title="Go to end"
>▶|</button>
<button
class="action-btn danger"
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
title="Delete clip"
>✕</button>
</div>
{/if}
</div>
{:else}
<!-- Bottom (wide row) layout -->
<span class="color-swatch" style="background: {clip.color}"></span>
<input
class="label-input"
type="text"
value={clip.label}
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<input
class="time-input"
type="text"
value={formatTime(clip.startTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<span class="arrow">→</span>
<input
class="time-input"
type="text"
value={formatTime(clip.endTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
{#if clip.id === selectedId}
<div class="clip-actions inline">
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
title="Play clip"
>▶</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
title="Go to start"
>|◀</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
title="Go to end"
>▶|</button>
<button
class="action-btn danger"
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
title="Delete clip"
>✕</button>
</div>
{:else}
<button
class="delete-btn"
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
title="Delete clip"
>✕</button>
{/if}
{/if}
</div>
{/each}
<div class="action-buttons">
<div class="list-actions">
{#if selectedId}
<button type="button" class="deselect-btn" onclick={() => selectClip(null)}>Deselect</button>
{/if}
@@ -122,6 +233,12 @@
box-sizing: border-box;
}
.clip-list.sidebar {
border-top: none;
height: 100%;
padding: 8px 6px;
}
.empty {
color: var(--text-muted);
font-size: 13px;
@@ -136,7 +253,7 @@
margin-bottom: 4px;
}
.action-buttons {
.list-actions {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
@@ -144,7 +261,7 @@
gap: 8px;
}
.action-buttons button {
.list-actions button {
font-size: 13px;
padding: 6px 12px;
}
@@ -154,6 +271,7 @@
background: var(--bg-tertiary);
}
/* --- Bottom (wide) row layout --- */
.clip-row {
display: flex;
align-items: center;
@@ -176,6 +294,32 @@
background: var(--bg-tertiary);
}
/* --- Sidebar (vertical card) layout --- */
.clip-row.sidebar {
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 6px;
}
.clip-card {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.card-row {
display: flex;
align-items: center;
gap: 6px;
}
.card-row.times {
justify-content: center;
}
/* --- Shared elements --- */
.color-swatch {
width: 12px;
height: 12px;
@@ -192,6 +336,12 @@
padding: 2px 4px;
}
.label-input.sidebar-label {
flex: 1;
width: auto;
min-width: 0;
}
.label-input:focus {
background: var(--bg-primary);
outline: 1px solid var(--accent);
@@ -220,6 +370,44 @@
font-size: 12px;
}
/* --- Clip action buttons --- */
.clip-actions {
display: flex;
gap: 4px;
justify-content: center;
padding-top: 4px;
}
.clip-actions.inline {
padding-top: 0;
margin-left: auto;
flex-shrink: 0;
}
.action-btn {
font-size: 12px;
padding: 2px 6px;
color: var(--text-secondary);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
line-height: 1.2;
}
.action-btn:hover {
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
.action-btn.danger:hover {
background: var(--danger);
color: var(--bg-primary);
border-color: var(--danger);
}
/* Legacy delete button for unselected clips in bottom mode */
.delete-btn {
font-size: 14px;
padding: 2px 6px;

View File

@@ -136,8 +136,25 @@
}
</script>
<div class="overlay" role="presentation" onclick={onClose}>
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
<div
class="overlay"
role="presentation"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
if (e.key !== 'Escape') e.preventDefault();
onClose();
}
}}
>
<div
class="dialog"
role="dialog"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2>Export Clips</h2>
{#if !isExportReady}

View File

@@ -6,6 +6,8 @@
setCookieSource,
type CookieSource,
} from '$lib/stores/preferences.svelte';
import { getCacheStats, clearAllCache } from '$lib/bindings/cache';
import type { CacheStats } from '$lib/bindings/cache';
let { onClose }: { onClose: () => void } = $props();
@@ -13,6 +15,34 @@
let cookieFilePath = $state(
preferences.cookieSource.type === 'file' ? preferences.cookieSource.path : ''
);
let cacheStats = $state<CacheStats | null>(null);
let clearingCache = $state(false);
$effect(() => {
getCacheStats().then((stats) => {
cacheStats = stats;
});
});
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const val = bytes / Math.pow(1024, i);
return `${val.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
async function handleClearAllCache() {
clearingCache = true;
try {
await clearAllCache();
cacheStats = { entryCount: 0, totalBytes: 0 };
} catch (err) {
console.error('Failed to clear cache:', err);
} finally {
clearingCache = false;
}
}
async function pickOutputDirectory() {
const selected = await open({
@@ -57,8 +87,25 @@
}
</script>
<div class="overlay" role="presentation" onclick={onClose}>
<div class="panel" role="dialog" onclick={(e) => e.stopPropagation()}>
<div
class="overlay"
role="presentation"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
if (e.key !== 'Escape') e.preventDefault();
onClose();
}
}}
>
<div
class="panel"
role="dialog"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2>Preferences</h2>
<section>
@@ -111,6 +158,23 @@
</div>
</section>
<section>
<h3>Cache</h3>
{#if cacheStats}
<p class="cache-info">
{formatBytes(cacheStats.totalBytes)} used across {cacheStats.entryCount} video{cacheStats.entryCount === 1 ? '' : 's'}
</p>
{:else}
<p class="cache-info">Loading…</p>
{/if}
<button
onclick={handleClearAllCache}
disabled={clearingCache || (cacheStats?.entryCount === 0)}
>
{clearingCache ? 'Clearing…' : 'Clear All Cache'}
</button>
</section>
<div class="actions">
<button class="primary" onclick={onClose}>Done</button>
</div>
@@ -190,6 +254,12 @@
margin-left: 20px;
}
.cache-info {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.actions {
display: flex;
justify-content: flex-end;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getVersion } from '@tauri-apps/api/app';
import { session, upgradePreview } from '$lib/stores/videoSession.svelte';
import { session, upgradePreview, reprocessCurrentVideo } from '$lib/stores/videoSession.svelte';
let { onOpenAbout }: { onOpenAbout?: () => void } = $props();
@@ -25,6 +25,23 @@
let isPreviewComplete = $derived(session.previewStatus === 'complete');
let isExportDownloading = $derived(session.exportStatus === 'downloading');
let isExportComplete = $derived(session.exportStatus === 'complete');
let canReprocess = $derived(
session.status === 'ready' &&
session.processingStep === 'done' &&
session.activeVideoPath !== null
);
let reprocessing = $state(false);
async function handleReprocess() {
reprocessing = true;
try {
await reprocessCurrentVideo();
} finally {
reprocessing = false;
}
}
</script>
<div class="status-bar">
@@ -53,6 +70,16 @@
{:else}
<span class="status-text">Ready</span>
{/if}
{#if canReprocess && !isPreviewDownloading}
<button
class="reprocess-btn"
onclick={handleReprocess}
disabled={reprocessing}
title="Reprocess video (re-extract waveform, thumbnails, keyframes)"
>
{reprocessing ? '⏳' : '↻'}
</button>
{/if}
{#if appVersion}
<button class="version-btn" onclick={() => onOpenAbout?.()} title="About GUI Video Clipper">
v{appVersion}
@@ -108,6 +135,27 @@
color: var(--bg-primary, #1e1e2e);
}
.reprocess-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 14px;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
line-height: 1;
}
.reprocess-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.reprocess-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.version-btn {
margin-left: auto;
background: none;

View File

@@ -1,11 +1,16 @@
<script lang="ts">
import TimelineContextMenu from '$lib/components/TimelineContextMenu.svelte';
import type { ContextMenuAction } from '$lib/components/TimelineContextMenu.svelte';
import { session } from '$lib/stores/videoSession.svelte';
import {
clipStore,
selectClip,
updateClip,
markInPoint,
markOutPoint,
removeClip,
} from '$lib/stores/clips.svelte';
import { drawTimeline, type TimelineState } from '$lib/timeline/renderer';
import { drawTimeline, type TimelineState, computeLanes } from '$lib/timeline/renderer';
import {
computeZoom,
panBy,
@@ -36,6 +41,12 @@
let isMinimapDragging = $state(false);
let minimapDragStartX = $state(0);
let minimapDragStartVisibleStart = $state(0);
let mouseX = $state<number | null>(null);
let contextMenu = $state<{
x: number;
y: number;
time: number;
} | null>(null);
let isZoomed = $derived(timelineState.zoom > 1.01);
@@ -83,7 +94,9 @@
clipStore.selectedClipId,
clipStore.pendingInPoint,
waveformData,
session.thumbnailSpritesheets
session.thumbnailSpritesheets,
session.status === 'ready',
mouseX
);
ctx.restore();
}
@@ -153,6 +166,7 @@
void timelineState.width;
void timelineState.height;
void canvas;
void mouseX;
requestDraw();
});
@@ -231,11 +245,62 @@
}
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
const x = getCanvasX(e);
const time = computeClickTime(x, timelineState, session.duration);
seekTo(time);
contextMenu = {
x: e.clientX,
y: e.clientY,
time,
};
}
function handleContextMenuAction(action: ContextMenuAction) {
if (!contextMenu) return;
switch (action.type) {
case 'mark-in':
markInPoint(contextMenu.time);
break;
case 'mark-out':
markOutPoint(contextMenu.time);
break;
case 'go-to-clip-start': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) seekTo(clip.startTime);
break;
}
case 'go-to-clip-end': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) seekTo(clip.endTime);
break;
}
case 'delete-clip':
if (clipStore.selectedClipId) removeClip(clipStore.selectedClipId);
break;
case 'play-clip': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) {
seekTo(clip.startTime);
const videoEl = document.querySelector('video');
if (videoEl?.paused) videoEl.play();
}
break;
}
default: {
const _exhaustive: never = action;
void _exhaustive;
}
}
}
function handleMouseDown(e: MouseEvent) {
contextMenu = null;
const x = getCanvasX(e);
const y = getCanvasY(e);
const hit = hitTestClip(x, y, timelineState, clipStore.clips);
const hit = hitTestClip(x, y, timelineState, clipStore.clips, computeLanes(timelineState.height));
if (hit) {
if (hit.edge === 'body') {
selectClip(hit.clipId);
@@ -283,8 +348,18 @@
dragTarget = null;
}
function handleMouseTrack(e: MouseEvent) {
mouseX = getCanvasX(e);
}
function handleMouseLeave() {
handleMouseUp();
mouseX = null;
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
contextMenu = null;
if (e.deltaX !== 0) {
const result = panBy(timelineState, e.deltaX, session.duration);
@@ -375,14 +450,31 @@
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmousemove={(e) => {
handleMouseMove(e);
handleMouseTrack(e);
}}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
onmouseleave={handleMouseLeave}
onwheel={handleWheel}
oncontextmenu={handleContextMenu}
></canvas>
</div>
</div>
{#if contextMenu}
<TimelineContextMenu
x={contextMenu.x}
y={contextMenu.y}
time={contextMenu.time}
selectedClipId={clipStore.selectedClipId}
onAction={handleContextMenuAction}
onClose={() => {
contextMenu = null;
}}
/>
{/if}
<svelte:window
onmousemove={handleMinimapMouseMove}
onmouseup={handleMinimapMouseUp}

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import { formatTime } from '$lib/utils/time';
export type ContextMenuAction =
| { type: 'mark-in' }
| { type: 'mark-out' }
| { type: 'go-to-clip-start' }
| { type: 'go-to-clip-end' }
| { type: 'play-clip' }
| { type: 'delete-clip' };
let {
x,
y,
time,
selectedClipId,
onAction,
onClose,
}: {
x: number;
y: number;
time: number;
selectedClipId: string | null;
onAction: (action: ContextMenuAction) => void;
onClose: () => void;
} = $props();
let menuEl = $state<HTMLDivElement | null>(null);
function handleAction(action: ContextMenuAction) {
onAction(action);
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
function handleClickOutside(e: MouseEvent) {
if (menuEl && !menuEl.contains(e.target as Node)) {
onClose();
}
}
let adjustedX = $state(0);
let adjustedY = $state(0);
$effect(() => {
const posX = x;
const posY = y;
if (!menuEl) {
adjustedX = posX;
adjustedY = posY;
return;
}
const rect = menuEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let newX = posX;
let newY = posY;
if (posX + rect.width > vw) {
newX = posX - rect.width;
}
if (posY + rect.height > vh) {
newY = posY - rect.height;
}
adjustedX = Math.max(0, newX);
adjustedY = Math.max(0, newY);
});
</script>
<svelte:window
onmousedown={handleClickOutside}
onkeydown={handleKeydown}
/>
<div
class="context-menu"
bind:this={menuEl}
style="left: {adjustedX}px; top: {adjustedY}px;"
role="menu"
>
<div class="menu-header">{formatTime(time)}</div>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'mark-in' })}
>
<span class="label">Mark In</span>
<span class="shortcut">I</span>
</button>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'mark-out' })}
>
<span class="label">Mark Out</span>
<span class="shortcut">O</span>
</button>
<div class="separator"></div>
<button
class="menu-item"
class:disabled={!selectedClipId}
role="menuitem"
disabled={!selectedClipId}
onclick={() => handleAction({ type: 'go-to-clip-start' })}
>
<span class="label">Go to Clip Start</span>
</button>
<button
class="menu-item"
class:disabled={!selectedClipId}
role="menuitem"
disabled={!selectedClipId}
onclick={() => handleAction({ type: 'go-to-clip-end' })}
>
<span class="label">Go to Clip End</span>
</button>
{#if selectedClipId}
<div class="separator"></div>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'play-clip' })}
>
<span class="label">Play Clip</span>
<span class="shortcut">▶</span>
</button>
<button
class="menu-item danger"
role="menuitem"
onclick={() => handleAction({ type: 'delete-clip' })}
>
<span class="label">Delete Clip</span>
<span class="shortcut">⌫</span>
</button>
{/if}
</div>
<style>
.context-menu {
position: fixed;
z-index: 1000;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 0;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
font-size: 13px;
}
.menu-header {
padding: 4px 12px 2px;
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
border-radius: 0;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
}
.menu-item:hover:not(:disabled) {
background: var(--accent);
color: var(--bg-primary);
}
.menu-item:disabled {
color: var(--text-muted);
cursor: default;
opacity: 0.5;
}
.menu-item.danger:hover {
background: var(--danger);
color: var(--bg-primary);
}
.shortcut {
font-size: 11px;
color: var(--text-muted);
margin-left: 16px;
}
.menu-item:hover:not(:disabled) .shortcut {
color: inherit;
}
.separator {
height: 1px;
background: var(--border);
margin: 4px 0;
}
</style>

View File

@@ -135,6 +135,10 @@
function handleLoadedData() {
videoError = null;
// Restore playhead position (e.g. after layout toggle remounts the video)
if (videoElement && session.currentTime > 0) {
videoElement.currentTime = session.currentTime;
}
if (videoElement) {
console.log('[VideoPlayer] loadeddata:', {
src: videoElement.src,
@@ -194,7 +198,9 @@
onloadeddata={handleLoadedData}
preload="metadata"
playsinline
></video>
>
<track kind="captions" />
</video>
{#if activeCues.length > 0}
<div class="caption-overlay" style={captionPosition}>
{#each activeCues as cue}

View File

@@ -47,12 +47,16 @@ export interface Preferences {
outputDirectory: string;
cookieSource: CookieSource;
captionSettings: CaptionSettings;
clipListPosition: 'bottom' | 'left';
clipListWidth: number;
}
const DEFAULT_PREFERENCES: Preferences = {
outputDirectory: '~/',
cookieSource: { type: 'browser', browser: 'firefox' },
captionSettings: { ...DEFAULT_CAPTION_SETTINGS },
clipListPosition: 'bottom',
clipListWidth: 220,
};
let store: Store | null = null;
@@ -73,6 +77,10 @@ export async function loadPreferences(): Promise<void> {
if (savedDir) preferences.outputDirectory = savedDir;
if (savedCookie) preferences.cookieSource = savedCookie;
if (savedCaptions) preferences.captionSettings = { ...DEFAULT_CAPTION_SETTINGS, ...savedCaptions };
const savedClipListPosition = await store.get<'bottom' | 'left'>('clipListPosition');
const savedClipListWidth = await store.get<number>('clipListWidth');
if (savedClipListPosition) preferences.clipListPosition = savedClipListPosition;
if (savedClipListWidth != null) preferences.clipListWidth = savedClipListWidth;
})();
await loadPromise;
}
@@ -85,6 +93,8 @@ export async function savePreferences(): Promise<void> {
await store.set('outputDirectory', preferences.outputDirectory);
await store.set('cookieSource', preferences.cookieSource);
await store.set('captionSettings', preferences.captionSettings);
await store.set('clipListPosition', preferences.clipListPosition);
await store.set('clipListWidth', preferences.clipListWidth);
await store.save();
}
@@ -102,3 +112,13 @@ export async function setCaptionSettings(settings: Partial<CaptionSettings>): Pr
Object.assign(preferences.captionSettings, settings);
await savePreferences();
}
export async function setClipListPosition(position: 'bottom' | 'left'): Promise<void> {
preferences.clipListPosition = position;
await savePreferences();
}
export async function setClipListWidth(width: number): Promise<void> {
preferences.clipListWidth = width;
await savePreferences();
}

View File

@@ -10,6 +10,12 @@ import {
} from '$lib/bindings/mediaAnalysis';
import { clearThumbnailCache } from '$lib/timeline/thumbnailRenderer';
import { clearAll as clearAllClips } from '$lib/stores/clips.svelte';
import {
loadCachedAnalysis,
saveAnalysisToCache,
getThumbnailCacheDir,
clearVideoCache,
} from '$lib/bindings/cache';
export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error';
export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed';
@@ -159,7 +165,7 @@ export function dismissUpgradeToast() {
session.showUpgradeToast = false;
}
async function loadCaptions(filePath: string) {
async function loadCaptionsWithCache(filePath: string) {
session.processingStep = 'captions';
// Priority 1: Check for embedded subtitle streams in the file
@@ -187,10 +193,38 @@ async function loadCaptions(filePath: string) {
async function triggerPostDownloadProcessing(filePath: string) {
const duration = session.duration;
// Run processing SEQUENTIALLY to avoid overloading the CPU with
// concurrent ffmpeg subprocesses. Each one decodes the full file.
// Order: waveform first (most useful for editing), then keyframes, then thumbnails.
// Check cache first
try {
const cached = await loadCachedAnalysis(session.url);
if (cached) {
console.log('[triggerPostDownloadProcessing] cache hit for', session.url);
if (cached.waveformTiers) {
session.waveformTiers = cached.waveformTiers;
}
if (cached.keyframePositions.length > 0) {
session.keyframePositions = cached.keyframePositions;
}
if (cached.thumbnailSpritesheets.length > 0) {
session.thumbnailSpritesheets = cached.thumbnailSpritesheets;
}
if (cached.captionFilePath) {
session.captionFilePath = cached.captionFilePath;
} else {
// Captions weren't cached — re-extract
await loadCaptionsWithCache(filePath);
}
session.processingStep = 'done';
if (session.exportStatus === 'complete' && session.exportFilePath
&& session.activeVideoPath !== session.exportFilePath) {
session.showUpgradeToast = true;
}
return;
}
} catch (err) {
console.error('Cache load failed, proceeding with extraction:', err);
}
// Cache miss — run extraction sequentially
session.processingStep = 'waveform';
session.processingProgress = 0;
try {
@@ -214,19 +248,36 @@ async function triggerPostDownloadProcessing(filePath: string) {
session.processingStep = 'thumbnails';
session.processingProgress = 0;
try {
const sheets = await extractThumbnails(filePath, duration);
const thumbDir = await getThumbnailCacheDir(session.url);
const sheets = await extractThumbnails(filePath, duration, thumbDir);
session.thumbnailSpritesheets = sheets;
} catch (err) {
console.error('Thumbnail extraction failed:', err);
}
// Captions can run after analysis since they use yt-dlp, not ffmpeg
await loadCaptions(filePath);
// Captions — use cache dir for output
await loadCaptionsWithCache(filePath);
session.processingStep = 'done';
// If export download already completed while we were processing, show upgrade toast
if (session.exportStatus === 'complete' && session.exportFilePath) {
// Save to cache
try {
await saveAnalysisToCache(
session.url,
session.title,
session.duration,
session.fps,
session.waveformTiers,
session.keyframePositions,
session.thumbnailSpritesheets,
session.captionFilePath
);
} catch (err) {
console.error('Failed to save analysis to cache:', err);
}
if (session.exportStatus === 'complete' && session.exportFilePath
&& session.activeVideoPath !== session.exportFilePath) {
session.showUpgradeToast = true;
}
}
@@ -262,6 +313,23 @@ export async function beginDownload() {
session.processingStep = 'downloading';
// --- Check export (HQ) cache first ---
const exportCached = await checkCachedDownload(session.title, 'export').catch(() => null);
if (exportCached) {
// HQ file already on disk — use it directly, skip preview entirely
session.exportFilePath = exportCached;
session.exportProgress = 1.0;
session.exportStatus = 'complete';
session.previewFilePath = exportCached;
session.previewProgress = 1.0;
session.previewStatus = 'complete';
session.activeVideoPath = exportCached;
// Prefer preview file for analysis (much faster ffmpeg operations)
const previewForProcessing = await checkCachedDownload(session.title, 'preview').catch(() => null);
triggerPostDownloadProcessing(previewForProcessing || exportCached);
return;
}
// --- Preview download (low-res, fast) ---
const previewCached = await checkCachedDownload(session.title, 'preview').catch(() => null);
if (previewCached) {
@@ -305,45 +373,66 @@ export async function beginDownload() {
}
// --- Export download (best quality, background) ---
const exportCached = await checkCachedDownload(session.title, 'export').catch(() => null);
if (exportCached) {
session.exportFilePath = exportCached;
session.exportProgress = 1.0;
session.exportStatus = 'complete';
if ((session.processingStep as ProcessingStep) === 'done') {
session.showUpgradeToast = true;
}
} else {
session.exportStatus = 'downloading';
session.exportProgress = 0;
session.exportFilePath = null;
// Only start if not already cached (we handled cached case above with early return)
session.exportStatus = 'downloading';
session.exportProgress = 0;
session.exportFilePath = null;
// Fire-and-forget — runs in background
runDownload(
session.url,
EXPORT_FORMAT,
'export',
(pct) => { session.exportProgress = pct; },
(path) => { session.exportFilePath = path; },
(success, path) => {
if (success) {
session.exportProgress = 1.0;
session.exportFilePath = path;
session.exportStatus = 'complete';
if ((session.processingStep as ProcessingStep) === 'done') {
session.showUpgradeToast = true;
}
} else {
session.exportStatus = 'failed';
runDownload(
session.url,
EXPORT_FORMAT,
'export',
(pct) => { session.exportProgress = pct; },
(path) => { session.exportFilePath = path; },
(success, path) => {
if (success) {
session.exportProgress = 1.0;
session.exportFilePath = path;
session.exportStatus = 'complete';
if ((session.processingStep as ProcessingStep) === 'done') {
session.showUpgradeToast = true;
}
},
(message) => {
} else {
session.exportStatus = 'failed';
console.error('Export download error:', message);
}
).catch((e) => {
},
(message) => {
session.exportStatus = 'failed';
console.error('Export download failed:', e);
});
}
console.error('Export download error:', message);
}
).catch((e) => {
session.exportStatus = 'failed';
console.error('Export download failed:', e);
});
}
/** Clear cache for the current video and re-extract all analysis data. */
export async function reprocessCurrentVideo() {
if (!session.url || !session.activeVideoPath) return;
// Always use the low-res preview file for processing (never the HQ export)
let processingPath: string | null = session.previewFilePath;
if (!processingPath || processingPath === session.exportFilePath) {
// Try to find a cached preview file on disk
processingPath = await checkCachedDownload(session.title, 'preview').catch(() => null);
}
// Last resort: fall back to whatever is active (should rarely happen)
const filePath = processingPath || session.activeVideoPath;
// Clear cache
try {
await clearVideoCache(session.url);
} catch (err) {
console.error('Failed to clear video cache:', err);
}
// Reset analysis state
clearThumbnailCache();
session.keyframePositions = [];
session.waveformTiers = null;
session.thumbnailSpritesheets = [];
session.captionFilePath = null;
// Re-run processing (will miss cache and extract fresh)
await triggerPostDownloadProcessing(filePath);
}

View File

@@ -1,8 +1,6 @@
import type { Clip } from '$lib/stores/clips.svelte';
import { timeToX, type TimelineState } from './renderer';
import { timeToX, type TimelineState, type LaneLayout } from './renderer';
const CLIP_LANE_Y = 90;
const CLIP_LANE_HEIGHT = 30;
const HANDLE_WIDTH = 6;
export function drawClips(
@@ -10,24 +8,28 @@ export function drawClips(
state: TimelineState,
clips: Clip[],
selectedId: string | null,
pendingInPoint: number | null
pendingInPoint: number | null,
lanes: LaneLayout
): void {
const clipY = lanes.clipY;
const clipH = lanes.clipHeight;
for (const clip of clips) {
const x1 = timeToX(clip.startTime, state);
const x2 = timeToX(clip.endTime, state);
const isSelected = clip.id === selectedId;
ctx.fillStyle = clip.color + '66';
ctx.fillRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
ctx.fillRect(x1, clipY, x2 - x1, clipH);
ctx.strokeStyle = isSelected ? '#ffffff' : clip.color;
ctx.lineWidth = isSelected ? 2 : 1;
ctx.strokeRect(x1, CLIP_LANE_Y, x2 - x1, CLIP_LANE_HEIGHT);
ctx.strokeRect(x1, clipY, x2 - x1, clipH);
ctx.lineWidth = 1;
ctx.fillStyle = isSelected ? '#ffffff' : clip.color;
ctx.fillRect(x1 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
ctx.fillRect(x2 - HANDLE_WIDTH / 2, CLIP_LANE_Y, HANDLE_WIDTH, CLIP_LANE_HEIGHT);
ctx.fillRect(x1 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
ctx.fillRect(x2 - HANDLE_WIDTH / 2, clipY, HANDLE_WIDTH, clipH);
const labelWidth = x2 - x1;
if (labelWidth > 40) {
@@ -37,7 +39,7 @@ export function drawClips(
ctx.fillText(
clip.label,
(x1 + x2) / 2,
CLIP_LANE_Y + CLIP_LANE_HEIGHT / 2 + 4,
clipY + clipH / 2 + 4,
labelWidth - 10
);
}
@@ -49,8 +51,8 @@ export function drawClips(
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(x, CLIP_LANE_Y);
ctx.lineTo(x, CLIP_LANE_Y + CLIP_LANE_HEIGHT);
ctx.moveTo(x, clipY);
ctx.lineTo(x, clipY + clipH);
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = 1;
@@ -66,9 +68,10 @@ export function hitTestClip(
x: number,
y: number,
state: TimelineState,
clips: Clip[]
clips: Clip[],
lanes: LaneLayout
): ClipHitResult | null {
if (y < CLIP_LANE_Y || y > CLIP_LANE_Y + CLIP_LANE_HEIGHT) {
if (y < lanes.clipY || y > lanes.clipY + lanes.clipHeight) {
return null;
}

View File

@@ -23,10 +23,40 @@ export function xToTime(x: number, state: TimelineState): number {
return state.visibleStart + (x / state.width) * range;
}
const THUMB_LANE_HEIGHT = 50;
const WAVEFORM_LANE_HEIGHT = 40;
const CONTROLS_LANE_HEIGHT = 30;
const TICK_HEIGHT = 10;
const BASE_FONT_SIZE = 12;
const MAGNIFY_BONUS = 10;
const MAGNIFY_RADIUS = 100;
const CONTROLS_HEIGHT = 22;
const CLIP_HEIGHT = 20;
export interface LaneLayout {
thumbY: number;
thumbHeight: number;
waveformY: number;
waveformHeight: number;
clipY: number;
clipHeight: number;
controlsY: number;
controlsHeight: number;
}
export function computeLanes(totalHeight: number): LaneLayout {
const available = Math.max(0, totalHeight - CONTROLS_HEIGHT - CLIP_HEIGHT);
const thumbHeight = Math.max(20, Math.round(available * 0.55));
const waveformHeight = Math.max(15, available - thumbHeight);
return {
thumbY: 0,
thumbHeight,
waveformY: thumbHeight,
waveformHeight,
clipY: thumbHeight + waveformHeight,
clipHeight: CLIP_HEIGHT,
controlsY: totalHeight - CONTROLS_HEIGHT,
controlsHeight: CONTROLS_HEIGHT,
};
}
export function drawTimeline(
ctx: CanvasRenderingContext2D,
@@ -37,7 +67,9 @@ export function drawTimeline(
selectedClipId: string | null = null,
pendingInPoint: number | null = null,
waveform: WaveformData = { tiers: null },
thumbnailSpritesheets: ThumbnailSpritesheet[] = []
thumbnailSpritesheets: ThumbnailSpritesheet[] = [],
hasVideo: boolean = false,
mouseX: number | null = null
): void {
const { width, height } = state;
ctx.clearRect(0, 0, width, height);
@@ -45,21 +77,23 @@ export function drawTimeline(
ctx.fillStyle = '#181825';
ctx.fillRect(0, 0, width, height);
drawTimeTicks(ctx, state, duration);
const lanes = computeLanes(height);
drawTimeTicks(ctx, state, duration, mouseX, lanes);
if (thumbnailSpritesheets.length > 0) {
drawThumbnails(ctx, state, thumbnailSpritesheets, duration, 0, THUMB_LANE_HEIGHT);
drawThumbnails(ctx, state, thumbnailSpritesheets, duration, lanes.thumbY, lanes.thumbHeight);
} else {
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails');
drawPlaceholderLane(ctx, state, lanes.thumbY, lanes.thumbHeight, 'Thumbnails', hasVideo);
}
if (waveform.tiers) {
drawWaveform(ctx, state, waveform, duration, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT);
drawWaveform(ctx, state, waveform, duration, lanes.waveformY, lanes.waveformHeight);
} else {
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform');
drawPlaceholderLane(ctx, state, lanes.waveformY, lanes.waveformHeight, 'Waveform', hasVideo);
}
drawClips(ctx, state, clips, selectedClipId, pendingInPoint);
drawClips(ctx, state, clips, selectedClipId, pendingInPoint, lanes);
drawPlayhead(ctx, state, currentTime);
}
@@ -67,27 +101,66 @@ export function drawTimeline(
function drawTimeTicks(
ctx: CanvasRenderingContext2D,
state: TimelineState,
duration: number
duration: number,
mouseX: number | null,
lanes: LaneLayout
): void {
const range = state.visibleEnd - state.visibleStart;
const tickInterval = getTickInterval(range, state.width);
const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval;
ctx.fillStyle = '#6c7086';
ctx.font = '10px -apple-system, sans-serif';
ctx.textAlign = 'center';
const baselineY = lanes.controlsY + TICK_HEIGHT + 10;
// Draw minor ticks first (behind major ticks)
const minorInterval = tickInterval / 4;
const minorTickHeight = TICK_HEIGHT * 0.5;
for (let t = startTick - tickInterval; t <= state.visibleEnd + minorInterval; t += minorInterval) {
if (t < 0 || t > duration) continue;
if (Math.abs(t % tickInterval) < 0.001 || Math.abs(t % tickInterval - tickInterval) < 0.001) continue;
const x = timeToX(t, state);
ctx.strokeStyle = '#35354a';
ctx.beginPath();
ctx.moveTo(x, lanes.controlsY);
ctx.lineTo(x, lanes.controlsY + minorTickHeight);
ctx.stroke();
}
// Draw major ticks with labels
for (let t = startTick; t <= state.visibleEnd; t += tickInterval) {
if (t < 0 || t > duration) continue;
const x = timeToX(t, state);
ctx.strokeStyle = '#45475a';
ctx.beginPath();
ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT);
ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT);
ctx.moveTo(x, lanes.controlsY);
ctx.lineTo(x, lanes.controlsY + TICK_HEIGHT);
ctx.stroke();
let fontSize = BASE_FONT_SIZE;
let scale = 0;
if (mouseX !== null) {
const distance = Math.abs(x - mouseX);
if (distance < MAGNIFY_RADIUS) {
scale = (1 + Math.cos((Math.PI * distance) / MAGNIFY_RADIUS)) / 2;
fontSize = BASE_FONT_SIZE + MAGNIFY_BONUS * scale;
}
}
// Interpolate color from muted (#6c7086) to bright (#cdd6f4)
if (scale > 0) {
const r = Math.round(108 + (205 - 108) * scale);
const g = Math.round(112 + (214 - 112) * scale);
const b = Math.round(134 + (244 - 134) * scale);
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
} else {
ctx.fillStyle = '#6c7086';
}
ctx.font = `${Math.round(fontSize)}px -apple-system, sans-serif`;
const label = formatTickLabel(t);
ctx.fillText(label, x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 12);
ctx.fillText(label, x, baselineY);
}
}
@@ -114,16 +187,19 @@ function drawPlaceholderLane(
state: TimelineState,
y: number,
height: number,
label: string
label: string,
showLabel: boolean
): void {
ctx.fillStyle = '#2a2a3e';
ctx.fillRect(0, y, state.width, height);
ctx.strokeStyle = '#353550';
ctx.strokeRect(0, y, state.width, height);
ctx.fillStyle = '#6c7086';
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Generating ${label}…`, state.width / 2, y + height / 2 + 4);
if (showLabel) {
ctx.fillStyle = '#6c7086';
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Generating ${label}…`, state.width / 2, y + height / 2 + 4);
}
}
export function drawPlayhead(

View File

@@ -62,8 +62,8 @@ describe('clip store', () => {
const id = getClips()[0].id;
selectClip(id);
markInPoint(5);
expect(getClips()[0].startTime).toBe(5);
markInPoint(12);
expect(getClips()[0].startTime).toBe(12);
expect(getPendingInPoint()).toBeNull();
});

View File

@@ -12,6 +12,7 @@
"module": "ESNext",
"target": "ESNext",
"isolatedModules": true,
"types": ["node"],
"paths": {
"$lib/*": ["./src/lib/*"]
}

View File

@@ -1,7 +1,7 @@
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
const __dirname = path.dirname(fileURLToPath(import.meta.url));