fix: double-paste bug, show actual yt-dlp errors in UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-21 11:57:08 -04:00
parent 149ce3da21
commit 6ffeed8419
3 changed files with 146 additions and 16 deletions

View File

@@ -0,0 +1,117 @@
# Video Clipper: Full SDD Implementation Summary
**Date:** 2026-09-21
**Approach:** Subagent-Driven Development (12 tasks + final review)
**Total commits:** 23 (0bc1f1e → e4710b4)
**Test suite:** 31 Vitest + 28 Rust tests, all green
## Task Description
Implemented the full MVP of a macOS GUI Video Clipper app (Tauri v2 + Svelte 5) across 12 tasks, each with an implementer subagent, task review, and fix rounds as needed. The app enables users to paste a YouTube URL, preview/scrub the video, mark clip regions, and export clips via ffmpeg.
## Changes Made
### Task 1: Project Scaffolding & App Shell
- Tauri v2 + Svelte 5 project with Rust backend
- Data models (`VideoMetadata`, `Clip`, `CookieSource`, `ExportConfig`, etc.)
- Utility functions (`formatTime`, `parseTime`, `getClipColor`) with Vitest tests
- App shell layout with 6 stub components
- CSS theme with custom properties
### Task 2: Dependency Manager & Setup Wizard
- Rust `dependency_manager` service (ffmpeg, yt-dlp, JS runtime, PO token detection)
- Tauri commands with Channel-based install progress streaming
- SetupWizard Svelte component with auto-continue
- **Fix round:** stderr threading for install output, cookie flags on PO token check
### Task 3: Preferences Store
- Svelte 5 rune store with `@tauri-apps/plugin-store` persistence
- Output directory + cookie source preferences
- PreferencesPanel component with gear toggle in toolbar
- **Fix round:** guard saves against uninitialized store
### Task 4: Video Resolver, URL Input & Video Player
- Rust `video_resolver` service (yt-dlp JSON parsing, stream URL extraction)
- `resolve_url` Tauri command with cookie support
- `videoSession` store (reactive session state)
- UrlInput (paste detection, resolve flow) + VideoPlayer (HTML5 `<video>`)
- **Fix round:** validate empty stream_url, clear stale metadata on error
### Task 5: Download Manager & Status Bar
- Rust `download_manager` service with progress parsing
- `start_download` command with Channel events
- StatusBar showing download progress
- Auto-download trigger after URL resolve
- **Fix round:** redirect unused stdout to `Stdio::null()`
### Task 6: Timeline Core
- Canvas-based timeline with `TimelineState`, coordinate mapping
- Playhead rendering, time ticks, placeholder lanes
- Mouse scrubbing, cursor-anchored zoom (wheel)
- 12 Vitest tests for pure functions
### Task 7: Clip Management
- `clips` store (Svelte 5 runes) with CRUD + in/out point marking
- Canvas clip renderer with color-coded regions + drag handles
- `hitTestClip` for mouse interaction
- ClipList component (list, edit, select, delete)
- 7 TDD tests for clip store
### Task 8: Transport Controls & Keyboard Shortcuts
- TransportControls component (play/pause, I/O, skip, frame step)
- Global keyboard handler (Space, I, O, J/K/L shuttle, arrows, comma/period)
- Shared `playback.ts` module for DRY transport logic
### Task 9: Keyframe Index & Frame Navigation
- Rust `keyframe_index` service (ffprobe parsing)
- `extract_keyframes` Tauri command
- Auto-fetch after download, wired to transport buttons (Shift+comma/period)
### Task 10: Waveform Generation & Display
- Rust `waveform_generator` service (ffmpeg audio peak extraction)
- Canvas waveform renderer with zoom-aware peak drawing
- Static placeholder while generating
- **Fix round:** clear waveform data on new download
### Task 11: Thumbnail Extraction & Display
- Rust `thumbnail_extractor` service (ffmpeg frame extraction)
- Adaptive density thumbnails on canvas timeline
- Image caching with `convertFileSrc` (Tauri asset protocol)
- **Fix round:** cache invalidation + temp directory cleanup
### Task 12: Clip Export & Export Dialog
- Rust `clip_exporter` service (lossless/precise, individual/merged)
- ExportDialog component (cut mode, scope, directory, progress)
- 6 Rust tests for ffmpeg command construction
- **Fix round:** temp dir cleanup on merge failure
### Final Review Fixes
- Clear clips on new video load
- WebM precise codec mapping (VP9/Opus)
- Home path `~` expansion via `dirs` crate
- computeZoom zero-range guard
- Timeline viewport reset on media switch
- Shuttle rate reset on new URL
## Follow-Up Items
These were identified in reviews as acceptable for MVP but should be addressed:
1. **Per-frame JPGs → sprite sheets** — Thumbnails use individual files instead of tiled grids; performance concern for very long videos
2. **Stream URL expiry handling** — No auto-re-resolve when streaming URL expires during preview
3. **Auto-export on download complete** — Spec calls for this when user is waiting in export dialog
4. **Merged export overlap trimming** — Overlapping clip ranges not trimmed before merge
5. **Export Selected** — ClipList only has Export All, not Export Selected
6. **Cmd+A select all clips** — Only preventDefault, no implementation
7. **Analysis failure UX** — Silent console.error instead of "Failed to generate" placeholder
8. **Zoom slider** — Only wheel zoom; no slider control
9. **Status bar ETA/export progress** — Minimal status bar info
10. **Dead `ExportEvent::Error` / `DownloadEvent::Error` variants** — Unused enum variants
## Lessons Learned
1. **Svelte 5 rune export limitation** — Can't export individual `$state` variables; must use a single `$state` object pattern (discovered in Task 3, applied throughout)
2. **Pipe deadlock pattern** — Piping both stdout and stderr without reading both causes subprocess deadlock. Fixed in Tasks 2 and 5 with either dual-thread reading or `Stdio::null()`.
3. **Stale state on session transitions** — Multiple tasks needed fixes for stale data (metadata, waveform, keyframes, clips) when loading a new URL. A centralized "new session" reset would prevent this pattern.
4. **ffmpeg `aresample` semantics** — Sets sample rate (Hz), not output length; long videos produce large PCM buffers before bucketing.
5. **SDD review loop value** — Per-task reviews caught real bugs (pipe deadlocks, stale state, missing cookie flags) that would have been expensive to debug later. The final whole-branch review caught cross-cutting issues (clip reset, codec mismatch) that per-task gates missed.

View File

@@ -33,32 +33,40 @@
function handlePaste(e: ClipboardEvent) {
const text = e.clipboardData?.getData('text')?.trim();
if (text && (text.startsWith('http://') || text.startsWith('https://'))) {
e.preventDefault();
inputValue = text;
setTimeout(handleSubmit, 50);
}
}
</script>
<div class="url-input">
<input
type="text"
placeholder="Paste a video URL and press Enter…"
bind:value={inputValue}
onkeydown={handleKeydown}
onpaste={handlePaste}
disabled={session.status === 'resolving'}
/>
{#if session.status === 'resolving'}
<span class="spinner">⏳</span>
{/if}
<div class="url-input-wrapper">
<div class="url-input">
<input
type="text"
placeholder="Paste a video URL and press Enter…"
bind:value={inputValue}
onkeydown={handleKeydown}
onpaste={handlePaste}
disabled={session.status === 'resolving'}
/>
{#if session.status === 'resolving'}
<span class="spinner">⏳</span>
{/if}
</div>
{#if session.error}
<span class="error" title={session.error}>⚠</span>
<div class="error-bar" title={session.error}>⚠ {session.error}</div>
{/if}
</div>
<style>
.url-input {
.url-input-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.url-input {
display: flex;
align-items: center;
gap: 8px;
@@ -69,8 +77,13 @@
.spinner {
animation: pulse 1s ease-in-out infinite;
}
.error {
.error-bar {
color: var(--danger);
font-size: 12px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
}
@keyframes pulse {

View File

@@ -61,7 +61,7 @@
{:else if session.status === 'resolving'}
<div class="placeholder">Loading video…</div>
{:else if session.status === 'error'}
<div class="placeholder error">Failed to load video. Check the URL and try again.</div>
<div class="placeholder error">{session.error ?? 'Failed to load video.'}</div>
{:else}
<div class="placeholder">Paste a URL above to load a video</div>
{/if}