Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c90b6765b7
|
|||
|
23b30be357
|
|||
|
124542b4c4
|
|||
|
279e27a9b5
|
|||
|
8c271773bd
|
|||
|
27c199e090
|
|||
|
923c7b0e23
|
|||
|
b371ec6c3e
|
|||
|
0b8bec2496
|
|||
|
91550dacd6
|
|||
|
9752a6d614
|
|||
|
9499d776fd
|
|||
|
925a3fc054
|
|||
|
36ff3094b6
|
|||
|
2c3d035d1f
|
|||
|
b180d4324c
|
|||
|
b7a5caf4ae
|
|||
|
6818938cb0
|
|||
|
b7fb7929f0
|
|||
|
1773ae81e6
|
|||
|
dc3e7945c6
|
|||
|
8bd2b34103
|
|||
|
90f598050c
|
|||
|
2f810111cc
|
|||
|
2055524d30
|
|||
|
d46973a719
|
|||
|
1ea7f19668
|
|||
|
30b04b42d4
|
|||
|
d85fbdac05
|
|||
|
9d1f1cfcf3
|
|||
|
3eebe01d63
|
|||
|
4bf65458d0
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,5 +9,6 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
dist/
|
||||
.cursor/
|
||||
.vscode/
|
||||
|
||||
10
README.md
10
README.md
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
86
chat-summaries/2026-09-22_14-50-v0.1.2-polish-summary.md
Normal file
86
chat-summaries/2026-09-22_14-50-v0.1.2-polish-summary.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
1498
docs/superpowers/plans/2026-09-22-v0.1.2-polish.md
Normal file
1498
docs/superpowers/plans/2026-09-22-v0.1.2-polish.md
Normal file
File diff suppressed because it is too large
Load Diff
1060
docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md
Normal file
1060
docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
211
docs/superpowers/specs/2026-09-22-v0.1.2-polish-design.md
Normal file
211
docs/superpowers/specs/2026-09-22-v0.1.2-polish-design.md
Normal 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.
|
||||
201
docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md
Normal file
201
docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md
Normal 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
22
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
82
scripts/build-release.sh
Executable 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
3
src-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
166
src-tauri/src/commands/cache.rs
Normal file
166
src-tauri/src/commands/cache.rs
Normal 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()
|
||||
}
|
||||
@@ -20,6 +20,7 @@ pub enum ExportEvent {
|
||||
Finished {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
#[allow(dead_code)] // Reserved for future error-channel reporting
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
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());
|
||||
.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)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod cache;
|
||||
pub mod dependencies;
|
||||
pub mod export;
|
||||
pub mod media_analysis;
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t);
|
||||
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!())
|
||||
|
||||
221
src-tauri/src/services/cache_manager.rs
Normal file
221
src-tauri/src/services/cache_manager.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod cache_manager;
|
||||
pub mod clip_exporter;
|
||||
pub mod dependency_manager;
|
||||
pub mod download_manager;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
213
src/App.svelte
213
src/App.svelte
@@ -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">
|
||||
<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 class="timeline-clip-area" bind:this={splitAreaEl}>
|
||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||
<Timeline />
|
||||
</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
60
src/lib/bindings/cache.ts
Normal 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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</label>
|
||||
<input
|
||||
type="range" min="12" max="36" step="1"
|
||||
value={settings.fontSize}
|
||||
oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
|
||||
/>
|
||||
</label>
|
||||
</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>
|
||||
</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 })}
|
||||
/>
|
||||
</label>
|
||||
</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>
|
||||
</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 })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -195,21 +198,21 @@
|
||||
<label>
|
||||
Shadow Depth
|
||||
<span class="value">{settings.shadowDepth}px</span>
|
||||
</label>
|
||||
<input
|
||||
type="range" min="1" max="5" step="1"
|
||||
value={settings.shadowDepth}
|
||||
oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
|
||||
/>
|
||||
</label>
|
||||
</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
|
||||
|
||||
@@ -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,6 +94,65 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#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"
|
||||
@@ -90,17 +178,40 @@
|
||||
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{#if clip.id === selectedId}
|
||||
<div class="clip-actions inline">
|
||||
<button
|
||||
class="delete-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeClip(clip.id);
|
||||
}}
|
||||
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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
215
src/lib/components/TimelineContextMenu.svelte
Normal file
215
src/lib/components/TimelineContextMenu.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,20 +373,11 @@ 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 {
|
||||
// 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,
|
||||
@@ -346,4 +405,34 @@ export async function beginDownload() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,17 +187,20 @@ 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);
|
||||
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(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"isolatedModules": true,
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"$lib/*": ["./src/lib/*"]
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user